@typeberry/lib 0.9.0-6850f84 → 0.9.0-a723180

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/package.json +1 -1
  2. package/packages/jam/config-node/node-config.d.ts +20 -2
  3. package/packages/jam/config-node/node-config.d.ts.map +1 -1
  4. package/packages/jam/config-node/node-config.js +25 -3
  5. package/packages/jam/config-node/node-config.test.js +8 -1
  6. package/packages/jam/database/serialized-states-db.d.ts +4 -1
  7. package/packages/jam/database/serialized-states-db.d.ts.map +1 -1
  8. package/packages/jam/database/serialized-states-db.js +17 -5
  9. package/packages/jam/database/serialized-states-db.test.d.ts +2 -0
  10. package/packages/jam/database/serialized-states-db.test.d.ts.map +1 -0
  11. package/packages/jam/database/serialized-states-db.test.js +109 -0
  12. package/packages/jam/database-fjall/blocks.d.ts +25 -0
  13. package/packages/jam/database-fjall/blocks.d.ts.map +1 -0
  14. package/packages/jam/database-fjall/blocks.js +86 -0
  15. package/packages/jam/database-fjall/blocks.test.d.ts +2 -0
  16. package/packages/jam/database-fjall/blocks.test.d.ts.map +1 -0
  17. package/packages/jam/database-fjall/blocks.test.js +62 -0
  18. package/packages/jam/database-fjall/hybrid-states.d.ts.map +1 -1
  19. package/packages/jam/database-fjall/hybrid-states.js +11 -2
  20. package/packages/jam/database-fjall/hybrid-states.test.js +94 -2
  21. package/packages/jam/database-fjall/index.d.ts +2 -0
  22. package/packages/jam/database-fjall/index.d.ts.map +1 -1
  23. package/packages/jam/database-fjall/index.js +2 -0
  24. package/packages/jam/database-fjall/root.d.ts +14 -8
  25. package/packages/jam/database-fjall/root.d.ts.map +1 -1
  26. package/packages/jam/database-fjall/root.js +25 -9
  27. package/packages/jam/database-fjall/states.d.ts +31 -0
  28. package/packages/jam/database-fjall/states.d.ts.map +1 -0
  29. package/packages/jam/database-fjall/states.js +99 -0
  30. package/packages/jam/database-fjall/states.test.d.ts +2 -0
  31. package/packages/jam/database-fjall/states.test.d.ts.map +1 -0
  32. package/packages/jam/database-fjall/states.test.js +150 -0
  33. package/packages/jam/database-lmdb/hybrid-states.test.js +94 -2
  34. package/packages/jam/node/export.d.ts.map +1 -1
  35. package/packages/jam/node/export.js +18 -9
  36. package/packages/jam/node/main-importer.d.ts +4 -2
  37. package/packages/jam/node/main-importer.d.ts.map +1 -1
  38. package/packages/jam/node/main-importer.js +26 -12
  39. package/packages/jam/node/main.d.ts.map +1 -1
  40. package/packages/jam/node/main.js +42 -15
  41. package/packages/jam/node/workers.d.ts +4 -4
  42. package/packages/jam/node/workers.d.ts.map +1 -1
  43. package/packages/workers/api/config.d.ts +2 -2
  44. package/packages/workers/api/config.d.ts.map +1 -1
  45. package/packages/workers/api/config.js +1 -1
  46. package/packages/workers/api/port.d.ts +5 -1
  47. package/packages/workers/api/port.d.ts.map +1 -1
  48. package/packages/workers/api/port.js +56 -13
  49. package/packages/workers/api/port.test.js +35 -0
  50. package/packages/workers/api-node/config.d.ts +45 -4
  51. package/packages/workers/api-node/config.d.ts.map +1 -1
  52. package/packages/workers/api-node/config.js +127 -9
  53. package/packages/workers/api-node/config.test.js +52 -3
  54. package/packages/workers/api-node/port.d.ts +3 -0
  55. package/packages/workers/api-node/port.d.ts.map +1 -1
  56. package/packages/workers/api-node/port.js +31 -2
  57. package/packages/workers/api-node/port.test.js +17 -0
  58. package/packages/workers/api-node/protocol.d.ts +3 -3
  59. package/packages/workers/api-node/protocol.d.ts.map +1 -1
  60. package/packages/workers/api-node/protocol.js +18 -12
  61. package/packages/workers/block-authorship/main.js +1 -1
  62. package/packages/workers/importer/finality.d.ts +4 -0
  63. package/packages/workers/importer/finality.d.ts.map +1 -1
  64. package/packages/workers/importer/finality.js +10 -1
  65. package/packages/workers/importer/finality.test.js +6 -0
  66. package/packages/workers/importer/importer.d.ts.map +1 -1
  67. package/packages/workers/importer/importer.js +3 -0
  68. package/packages/workers/importer/main.d.ts +1 -1
  69. package/packages/workers/importer/main.d.ts.map +1 -1
  70. package/packages/workers/importer/main.js +1 -1
  71. package/packages/workers/jam-network/main.js +1 -1
@@ -1,12 +1,14 @@
1
1
  import assert from "node:assert";
2
2
  import * as fs from "node:fs";
3
3
  import { afterEach, before, beforeEach, describe, it } from "node:test";
4
+ import { tryAsServiceId } from "#@typeberry/block";
4
5
  import { Bytes, BytesBlob } from "#@typeberry/bytes";
5
6
  import { tinyChainSpec } from "#@typeberry/config";
6
7
  import { Blake2b, HASH_SIZE } from "#@typeberry/hash";
7
- import { InMemoryState } from "#@typeberry/state";
8
+ import { InMemoryState, StorageItem, UpdateStorage, } from "#@typeberry/state";
9
+ import { testState } from "#@typeberry/state/test.utils.js";
8
10
  import { StateEntries } from "#@typeberry/state-merkleization";
9
- import { deepEqual, OK, Result } from "#@typeberry/utils";
11
+ import { asOpaqueType, deepEqual, OK, Result } from "#@typeberry/utils";
10
12
  import { FjallValuesSession, HybridSerializedStates } from "./hybrid-states.js";
11
13
  let blake2b;
12
14
  before(async () => {
@@ -111,3 +113,93 @@ describe("Fjall hybrid serialized states", () => {
111
113
  }
112
114
  });
113
115
  });
116
+ function hh(n) {
117
+ return Bytes.fill(HASH_SIZE, n).asOpaque();
118
+ }
119
+ const storageKey = asOpaqueType(BytesBlob.blobFromString("test-key"));
120
+ /** A state update writing a single large (non-embedded) value under `storageKey`. */
121
+ function storageUpdate(value) {
122
+ const item = StorageItem.create({ key: storageKey, value: BytesBlob.blobFromString(value) });
123
+ return {
124
+ storage: new Map([[tryAsServiceId(0), [UpdateStorage.set({ storage: item })]]]),
125
+ };
126
+ }
127
+ // > 32 bytes => stored in the values db (not embedded in the leaf).
128
+ const BIG_1 = "a".repeat(100);
129
+ const BIG_2 = "b".repeat(100);
130
+ /** `true` if every value referenced by the state can still be resolved. */
131
+ function canReadFully(state) {
132
+ if (state === null) {
133
+ return false;
134
+ }
135
+ try {
136
+ state.backend.intoStateEntries();
137
+ return true;
138
+ }
139
+ catch {
140
+ return false;
141
+ }
142
+ }
143
+ /** Value removals are queued, so poll for the expected outcome. */
144
+ async function eventually(check, what, timeoutMs = 5_000) {
145
+ const start = Date.now();
146
+ while (!check()) {
147
+ if (Date.now() - start > timeoutMs) {
148
+ assert.fail(`Timed out waiting for: ${what}`);
149
+ }
150
+ await new Promise((resolve) => setTimeout(resolve, 10));
151
+ }
152
+ }
153
+ describe("Fjall hybrid serialized states value refcounting", () => {
154
+ const spec = tinyChainSpec;
155
+ let dbPath = "";
156
+ beforeEach(() => {
157
+ dbPath = createTempDir();
158
+ });
159
+ afterEach(() => {
160
+ fs.rmSync(dbPath, { recursive: true });
161
+ });
162
+ it("removes a replaced value from disk once the replacement finalizes", async () => {
163
+ const states = await HybridSerializedStates.new({ spec, blake2b, dbPath });
164
+ try {
165
+ await states.insertInitialState(hh(0), StateEntries.serializeInMemory(spec, blake2b, testState()));
166
+ const s0 = states.getState(hh(0));
167
+ assert.ok(s0 !== null);
168
+ await states.updateAndSetState(hh(1), s0, storageUpdate(BIG_1));
169
+ // a handle to the post-1 state, surviving the pruning below
170
+ const stale1 = states.getState(hh(1));
171
+ const s1 = states.getState(hh(1));
172
+ assert.ok(s1 !== null);
173
+ await states.updateAndSetState(hh(2), s1, storageUpdate(BIG_2));
174
+ states.commitFinalized([hh(1)]);
175
+ assert.ok(canReadFully(stale1), "still referenced by the finalized tip");
176
+ states.commitFinalized([hh(2)]);
177
+ await eventually(() => !canReadFully(stale1), "replaced value removed from fjall");
178
+ assert.ok(canReadFully(states.getState(hh(2))), "the new finalized tip stays fully readable");
179
+ }
180
+ finally {
181
+ await states.close();
182
+ }
183
+ });
184
+ it("collects values of a pruned dead fork and keeps the surviving chain intact", async () => {
185
+ const states = await HybridSerializedStates.new({ spec, blake2b, dbPath });
186
+ try {
187
+ await states.insertInitialState(hh(0), StateEntries.serializeInMemory(spec, blake2b, testState()));
188
+ const s0 = states.getState(hh(0));
189
+ assert.ok(s0 !== null);
190
+ await states.updateAndSetState(hh(1), s0, storageUpdate(BIG_1));
191
+ // a dead fork on top of genesis, inserting a different value
192
+ const fork = states.getState(hh(0));
193
+ assert.ok(fork !== null);
194
+ await states.updateAndSetState(hh(0xaa), fork, storageUpdate(BIG_2));
195
+ const staleFork = states.getState(hh(0xaa));
196
+ states.markUnused(hh(0xaa));
197
+ assert.strictEqual(states.getState(hh(0xaa)), null);
198
+ await eventually(() => !canReadFully(staleFork), "fork-only value removed from fjall");
199
+ assert.ok(canReadFully(states.getState(hh(1))), "surviving chain is unaffected");
200
+ }
201
+ finally {
202
+ await states.close();
203
+ }
204
+ });
205
+ });
@@ -1,3 +1,5 @@
1
+ export * from "./blocks.js";
1
2
  export * from "./hybrid-states.js";
2
3
  export * from "./root.js";
4
+ export * from "./states.js";
3
5
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../../packages/jam/database-fjall/index.ts"],"names":[],"mappings":"AAAA,cAAc,oBAAoB,CAAC;AACnC,cAAc,WAAW,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../../packages/jam/database-fjall/index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAC;AAC5B,cAAc,oBAAoB,CAAC;AACnC,cAAc,WAAW,CAAC;AAC1B,cAAc,aAAa,CAAC"}
@@ -1,2 +1,4 @@
1
+ export * from "./blocks.js";
1
2
  export * from "./hybrid-states.js";
2
3
  export * from "./root.js";
4
+ export * from "./states.js";
@@ -1,13 +1,15 @@
1
- import { type Partition } from "@fjall-js/fjall";
2
- export type { Partition };
1
+ import { type Partition, type ReadonlyPartition } from "@fjall-js/fjall";
2
+ export type { Partition, ReadonlyPartition };
3
+ export type FjallPartition = Partition | ReadonlyPartition;
3
4
  /**
4
5
  * Normalize a value read from fjall (a Node `Buffer`) into a plain `Uint8Array`.
5
6
  */
6
7
  export declare function toUint8Array(value: Buffer | null): Uint8Array | null;
7
8
  export type FjallRootOptions = {
9
+ /** Open a read-only wrapper surface over the shared keyspace. */
10
+ readOnly?: boolean;
8
11
  /**
9
- * When set, we skip the durability flush (`persist`) and `close()` does not
10
- * do the sync-all fsync.
12
+ * When set, we skip explicit durability flushes (`persist`).
11
13
  *
12
14
  * Only safe for throwaway databases, like the fuzz target that wipes on every
13
15
  * reset.
@@ -16,7 +18,7 @@ export type FjallRootOptions = {
16
18
  /**
17
19
  * Cache size in bytes, shared by all partitions of the keyspace. fjall reads
18
20
  * through this cache, so it bounds how much we keep in memory. When not set,
19
- * fjall uses its own default.
21
+ * typeberry uses a conservative default.
20
22
  */
21
23
  cacheSizeBytes?: number;
22
24
  };
@@ -34,9 +36,13 @@ export declare class FjallRoot {
34
36
  private readonly options;
35
37
  private constructor();
36
38
  /** Open (or create) a fjall keyspace at the given path. */
37
- static open(dbPath: string, options: FjallRootOptions): Promise<FjallRoot>;
39
+ static open(dbPath: string, options?: FjallRootOptions): Promise<FjallRoot>;
40
+ /** Whether this root was opened through fjall's read-only surface. */
41
+ get readOnly(): boolean;
38
42
  /** Open (or create) a partition under this keyspace. */
39
- partition(name: string): Promise<Partition>;
43
+ partition(name: string): Promise<FjallPartition>;
44
+ /** Open a writable partition, failing early when this root is read-only. */
45
+ writablePartition(name: string): Promise<Partition>;
40
46
  /**
41
47
  * Flush the journal to disk so prior writes survive a crash.
42
48
  *
@@ -52,7 +58,7 @@ export declare class FjallRoot {
52
58
  * them recursively.
53
59
  */
54
60
  sizeInBytes(): number | null;
55
- /** Persist with `sync-all` and release the keyspace handle. */
61
+ /** Release this keyspace handle. Call persist() first when durability is needed. */
56
62
  close(): Promise<void>;
57
63
  }
58
64
  //# sourceMappingURL=root.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"root.d.ts","sourceRoot":"","sources":["../../../../../packages/jam/database-fjall/root.ts"],"names":[],"mappings":"AAEA,OAAO,EAAuB,KAAK,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAEtE,YAAY,EAAE,SAAS,EAAE,CAAC;AAE1B;;GAEG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,UAAU,GAAG,IAAI,CAKpE;AAED,MAAM,MAAM,gBAAgB,GAAG;IAC7B;;;;;;OAMG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB,CAAC;AAEF;;;;;;GAMG;AACH,qBAAa,SAAS;IAElB,OAAO,CAAC,QAAQ,CAAC,QAAQ;IACzB,+EAA+E;IAC/E,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAJ1B,OAAO;IAOP,2DAA2D;WAC9C,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,SAAS,CAAC;IAUhF,wDAAwD;IAClD,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,CAAC;IAIjD;;;;;OAKG;IACG,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAO9B;;;;;;OAMG;IACH,WAAW,IAAI,MAAM,GAAG,IAAI;IAQ5B,+DAA+D;IACzD,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;CAG7B"}
1
+ {"version":3,"file":"root.d.ts","sourceRoot":"","sources":["../../../../../packages/jam/database-fjall/root.ts"],"names":[],"mappings":"AAEA,OAAO,EAIL,KAAK,SAAS,EAEd,KAAK,iBAAiB,EACvB,MAAM,iBAAiB,CAAC;AAIzB,YAAY,EAAE,SAAS,EAAE,iBAAiB,EAAE,CAAC;AAC7C,MAAM,MAAM,cAAc,GAAG,SAAS,GAAG,iBAAiB,CAAC;AAE3D;;GAEG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,UAAU,GAAG,IAAI,CAKpE;AAED,MAAM,MAAM,gBAAgB,GAAG;IAC7B,iEAAiE;IACjE,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;;OAKG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB,CAAC;AAEF;;;;;;GAMG;AACH,qBAAa,SAAS;IAElB,OAAO,CAAC,QAAQ,CAAC,QAAQ;IACzB,+EAA+E;IAC/E,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAJ1B,OAAO;IAOP,2DAA2D;WAC9C,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,GAAE,gBAAqB,GAAG,OAAO,CAAC,SAAS,CAAC;IAWrF,sEAAsE;IACtE,IAAI,QAAQ,IAAI,OAAO,CAEtB;IAED,wDAAwD;IAClD,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,CAAC;IAItD,4EAA4E;IACtE,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,CAAC;IAOzD;;;;;OAKG;IACG,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAU9B;;;;;;OAMG;IACH,WAAW,IAAI,MAAM,GAAG,IAAI;IAQ5B,oFAAoF;IAC9E,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;CAG7B"}
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import { open } from "@fjall-js/fjall";
3
+ import { open, openReadonly, } from "@fjall-js/fjall";
4
+ const DEFAULT_CACHE_SIZE_BYTES = 64 * 1024 * 1024;
4
5
  /**
5
6
  * Normalize a value read from fjall (a Node `Buffer`) into a plain `Uint8Array`.
6
7
  */
@@ -29,19 +30,31 @@ export class FjallRoot {
29
30
  this.options = options;
30
31
  }
31
32
  /** Open (or create) a fjall keyspace at the given path. */
32
- static async open(dbPath, options) {
33
- // Forward our options to the binding: `ephemeral` makes `close()` skip the
34
- // sync-all fsync, `cacheSizeBytes` bounds how much we keep in memory.
35
- const keyspace = await open(dbPath, {
36
- ephemeral: options.ephemeral,
37
- cacheSizeBytes: options.cacheSizeBytes,
38
- });
33
+ static async open(dbPath, options = {}) {
34
+ // fjall-js 0.3 shares one engine per path. Readers must use the read-only
35
+ // wrapper surface; durability is driven explicitly through persist().
36
+ const config = {
37
+ path: dbPath,
38
+ cacheSizeBytes: options.cacheSizeBytes ?? DEFAULT_CACHE_SIZE_BYTES,
39
+ };
40
+ const keyspace = options.readOnly === true ? await openReadonly(config) : await open(config);
39
41
  return new FjallRoot(keyspace, dbPath, options);
40
42
  }
43
+ /** Whether this root was opened through fjall's read-only surface. */
44
+ get readOnly() {
45
+ return this.options.readOnly === true;
46
+ }
41
47
  /** Open (or create) a partition under this keyspace. */
42
48
  async partition(name) {
43
49
  return this.keyspace.partition(name);
44
50
  }
51
+ /** Open a writable partition, failing early when this root is read-only. */
52
+ async writablePartition(name) {
53
+ if (this.readOnly) {
54
+ throw new Error(`Cannot open writable fjall partition '${name}' from a read-only keyspace.`);
55
+ }
56
+ return (await this.keyspace.partition(name));
57
+ }
45
58
  /**
46
59
  * Flush the journal to disk so prior writes survive a crash.
47
60
  *
@@ -52,6 +65,9 @@ export class FjallRoot {
52
65
  if (this.options.ephemeral === true) {
53
66
  return;
54
67
  }
68
+ if (this.readOnly) {
69
+ throw new Error("Cannot persist a read-only fjall keyspace.");
70
+ }
55
71
  await this.keyspace.persist();
56
72
  }
57
73
  /**
@@ -69,7 +85,7 @@ export class FjallRoot {
69
85
  return null;
70
86
  }
71
87
  }
72
- /** Persist with `sync-all` and release the keyspace handle. */
88
+ /** Release this keyspace handle. Call persist() first when durability is needed. */
73
89
  async close() {
74
90
  await this.keyspace.close();
75
91
  }
@@ -0,0 +1,31 @@
1
+ import type { HeaderHash, StateRootHash } from "#@typeberry/block";
2
+ import type { ChainSpec } from "#@typeberry/config";
3
+ import { type InitStatesDb, LeafDb, type StatesDb, StateUpdateError } from "#@typeberry/database";
4
+ import type { Blake2b } from "#@typeberry/hash";
5
+ import type { ServicesUpdate, State } from "#@typeberry/state";
6
+ import type { StateEntries } from "#@typeberry/state-merkleization";
7
+ import { SerializedState } from "#@typeberry/state-merkleization";
8
+ import { OK, Result } from "#@typeberry/utils";
9
+ import { type FjallRoot } from "./root.js";
10
+ /** fjall-backed full on-disk serialized state storage. */
11
+ export declare class FjallStates implements StatesDb<SerializedState<LeafDb>>, InitStatesDb<StateEntries> {
12
+ private readonly spec;
13
+ private readonly blake2b;
14
+ private readonly root;
15
+ private readonly states;
16
+ private readonly values;
17
+ static open(spec: ChainSpec, blake2b: Blake2b, root: FjallRoot): Promise<FjallStates>;
18
+ private readonly valuesDb;
19
+ private constructor();
20
+ insertInitialState(headerHash: HeaderHash, entries: StateEntries): Promise<Result<OK, StateUpdateError>>;
21
+ updateAndSetState(headerHash: HeaderHash, state: SerializedState<LeafDb>, update: Partial<State & ServicesUpdate>): Promise<Result<OK, StateUpdateError>>;
22
+ getStateRoot(state: SerializedState<LeafDb>): Promise<StateRootHash>;
23
+ getState(headerHash: HeaderHash): SerializedState<LeafDb> | null;
24
+ commitFinalized(_headers: HeaderHash[]): void;
25
+ markUnused(headerHash: HeaderHash): void;
26
+ diskSizeInBytes(): number | null;
27
+ close(): Promise<void>;
28
+ private updateAndCommit;
29
+ private readValue;
30
+ }
31
+ //# sourceMappingURL=states.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"states.d.ts","sourceRoot":"","sources":["../../../../../packages/jam/database-fjall/states.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAGlE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AACnD,OAAO,EACL,KAAK,YAAY,EACjB,MAAM,EACN,KAAK,QAAQ,EACb,gBAAgB,EAGjB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,KAAK,EAAE,OAAO,EAAiB,MAAM,iBAAiB,CAAC;AAE9D,OAAO,KAAK,EAAE,cAAc,EAAE,KAAK,EAAE,MAAM,kBAAkB,CAAC;AAC9D,OAAO,KAAK,EAAE,YAAY,EAAY,MAAM,gCAAgC,CAAC;AAC7E,OAAO,EAAE,eAAe,EAAgD,MAAM,gCAAgC,CAAC;AAE/G,OAAO,EAAE,EAAE,EAAE,MAAM,EAAkB,MAAM,kBAAkB,CAAC;AAC9D,OAAO,EAAuB,KAAK,SAAS,EAAgC,MAAM,WAAW,CAAC;AAI9F,0DAA0D;AAC1D,qBAAa,WAAY,YAAW,QAAQ,CAAC,eAAe,CAAC,MAAM,CAAC,CAAC,EAAE,YAAY,CAAC,YAAY,CAAC;IAS7F,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,MAAM;WAZZ,IAAI,CAAC,IAAI,EAAE,SAAS,EAAE,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,SAAS,GAAG,OAAO,CAAC,WAAW,CAAC;IAK3F,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAW;IAEpC,OAAO;IAUD,kBAAkB,CAAC,UAAU,EAAE,UAAU,EAAE,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,MAAM,CAAC,EAAE,EAAE,gBAAgB,CAAC,CAAC;IAQxG,iBAAiB,CACrB,UAAU,EAAE,UAAU,EACtB,KAAK,EAAE,eAAe,CAAC,MAAM,CAAC,EAC9B,MAAM,EAAE,OAAO,CAAC,KAAK,GAAG,cAAc,CAAC,GACtC,OAAO,CAAC,MAAM,CAAC,EAAE,EAAE,gBAAgB,CAAC,CAAC;IAUlC,YAAY,CAAC,KAAK,EAAE,eAAe,CAAC,MAAM,CAAC,GAAG,OAAO,CAAC,aAAa,CAAC;IAI1E,QAAQ,CAAC,UAAU,EAAE,UAAU,GAAG,eAAe,CAAC,MAAM,CAAC,GAAG,IAAI;IAahE,eAAe,CAAC,QAAQ,EAAE,UAAU,EAAE,GAAG,IAAI;IAK7C,UAAU,CAAC,UAAU,EAAE,UAAU,GAAG,IAAI;IAMxC,eAAe,IAAI,MAAM,GAAG,IAAI;IAI1B,KAAK;YAEG,eAAe;IAyB7B,OAAO,CAAC,SAAS;CAOlB"}
@@ -0,0 +1,99 @@
1
+ import { BytesBlob } from "#@typeberry/bytes";
2
+ import { SortedSet } from "#@typeberry/collections";
3
+ import { LeafDb, StateUpdateError, updateLeafs, } from "#@typeberry/database";
4
+ import { Logger } from "#@typeberry/logger";
5
+ import { SerializedState, StateEntryUpdateAction, serializeStateUpdate } from "#@typeberry/state-merkleization";
6
+ import { leafComparator } from "#@typeberry/trie";
7
+ import { OK, Result, resultToString } from "#@typeberry/utils";
8
+ import { toUint8Array } from "./root.js";
9
+ const logger = Logger.new(import.meta.filename, "db");
10
+ /** fjall-backed full on-disk serialized state storage. */
11
+ export class FjallStates {
12
+ spec;
13
+ blake2b;
14
+ root;
15
+ states;
16
+ values;
17
+ static async open(spec, blake2b, root) {
18
+ const [states, values] = await Promise.all([root.partition("states"), root.partition("values")]);
19
+ return new FjallStates(spec, blake2b, root, states, values);
20
+ }
21
+ valuesDb;
22
+ constructor(spec, blake2b, root, states, values) {
23
+ this.spec = spec;
24
+ this.blake2b = blake2b;
25
+ this.root = root;
26
+ this.states = states;
27
+ this.values = values;
28
+ this.valuesDb = { get: (key) => this.readValue(key) };
29
+ }
30
+ async insertInitialState(headerHash, entries) {
31
+ return await this.updateAndCommit(headerHash, SortedSet.fromArray(leafComparator, []), Array.from(entries, (x) => [StateEntryUpdateAction.Insert, x[0], x[1]]));
32
+ }
33
+ async updateAndSetState(headerHash, state, update) {
34
+ const updatedValues = serializeStateUpdate(this.spec, this.blake2b, update);
35
+ const newLeafs = SortedSet.fromSortedArray(leafComparator, state.backend.leafs.array);
36
+ const res = await this.updateAndCommit(headerHash, newLeafs, updatedValues);
37
+ if (res.isOk) {
38
+ state.updateBackend(LeafDb.fromLeaves(newLeafs, this.valuesDb));
39
+ }
40
+ return res;
41
+ }
42
+ async getStateRoot(state) {
43
+ return state.backend.getStateRoot(this.blake2b);
44
+ }
45
+ getState(headerHash) {
46
+ const leafNodes = toUint8Array(this.states.get(headerHash.raw));
47
+ if (leafNodes === null) {
48
+ return null;
49
+ }
50
+ const leafDbResult = LeafDb.fromLeavesBlob(BytesBlob.blobFrom(leafNodes), this.valuesDb);
51
+ if (leafDbResult.isError) {
52
+ throw new Error(`Inconsistent DB. Invalid leaf nodes for ${headerHash}: ${resultToString(leafDbResult)}`);
53
+ }
54
+ return SerializedState.new(this.spec, this.blake2b, leafDbResult.ok);
55
+ }
56
+ commitFinalized(_headers) {
57
+ // Values are never pruned here. This db survives restarts, so refcounting
58
+ // would need counts persisted (and crash-consistent) alongside the values.
59
+ }
60
+ markUnused(headerHash) {
61
+ void writable(this.states, this.root)
62
+ .remove(headerHash.raw)
63
+ .catch((e) => logger.warn `Failed to prune state ${headerHash}: ${e}`);
64
+ }
65
+ diskSizeInBytes() {
66
+ return this.root.sizeInBytes();
67
+ }
68
+ async close() { }
69
+ async updateAndCommit(headerHash, leafs, data) {
70
+ const { values } = updateLeafs(leafs, this.blake2b, data);
71
+ const stateLeafs = BytesBlob.blobFromParts(leafs.array.map((x) => x.node.raw));
72
+ try {
73
+ // Preserve dependency order: values first, then leaves that may reference them.
74
+ if (values.length > 0) {
75
+ await writable(this.values, this.root).insertBatch(values.map(([hash, val]) => ({ key: hash.raw, value: val.raw })));
76
+ }
77
+ await writable(this.states, this.root).insert(headerHash.raw, stateLeafs.raw);
78
+ await this.root.persist();
79
+ }
80
+ catch (e) {
81
+ logger.error `${e}`;
82
+ return Result.error(StateUpdateError.Commit, () => `Failed to commit state update: ${e}`);
83
+ }
84
+ return Result.ok(OK);
85
+ }
86
+ readValue(key) {
87
+ const val = toUint8Array(this.values.get(key.raw));
88
+ if (val === null) {
89
+ throw new Error(`Missing required value: ${key} in the DB`);
90
+ }
91
+ return val;
92
+ }
93
+ }
94
+ function writable(partition, root) {
95
+ if (root.readOnly) {
96
+ throw new Error("Cannot write through a read-only fjall partition.");
97
+ }
98
+ return partition;
99
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=states.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"states.test.d.ts","sourceRoot":"","sources":["../../../../../packages/jam/database-fjall/states.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,150 @@
1
+ import assert from "node:assert";
2
+ import * as fs from "node:fs";
3
+ import { afterEach, before, beforeEach, describe, it } from "node:test";
4
+ import { tryAsServiceGas, tryAsServiceId, tryAsTimeSlot } from "#@typeberry/block";
5
+ import { Bytes, BytesBlob } from "#@typeberry/bytes";
6
+ import { tinyChainSpec } from "#@typeberry/config";
7
+ import { Blake2b, HASH_SIZE } from "#@typeberry/hash";
8
+ import { tryAsU32, tryAsU64 } from "#@typeberry/numbers";
9
+ import { InMemoryState, LookupHistoryItem, PrivilegedServices, ServiceAccountInfo, tryAsLookupHistorySlots, tryAsPerCore, UpdateService, } from "#@typeberry/state";
10
+ import { testState } from "#@typeberry/state/test.utils.js";
11
+ import { StateEntries } from "#@typeberry/state-merkleization";
12
+ import { deepEqual, OK, Result } from "#@typeberry/utils";
13
+ import { FjallRoot } from "./root.js";
14
+ import { FjallStates } from "./states.js";
15
+ let blake2b;
16
+ before(async () => {
17
+ blake2b = await Blake2b.createHasher();
18
+ });
19
+ describe("Fjall states database", () => {
20
+ const headerHash = Bytes.zero(HASH_SIZE).asOpaque();
21
+ const spec = tinyChainSpec;
22
+ let tmpDir = "";
23
+ beforeEach(() => {
24
+ tmpDir = fs.mkdtempSync("typeberry-fjall-states-");
25
+ });
26
+ afterEach(() => {
27
+ fs.rmSync(tmpDir, { recursive: true, force: true });
28
+ });
29
+ it("imports and reads an empty state", async () => {
30
+ const root = await FjallRoot.open(tmpDir, { ephemeral: true });
31
+ const states = await FjallStates.open(spec, blake2b, root);
32
+ try {
33
+ const emptyState = InMemoryState.empty(spec);
34
+ const serialized = StateEntries.serializeInMemory(spec, blake2b, emptyState);
35
+ const emptyRoot = serialized.getRootHash(blake2b);
36
+ deepEqual(await states.insertInitialState(headerHash, serialized), Result.ok(OK));
37
+ const newState = states.getState(headerHash);
38
+ assert.ok(newState !== null);
39
+ assert.strictEqual(`${await states.getStateRoot(newState)}`, `${emptyRoot}`);
40
+ deepEqual(InMemoryState.copyFrom(spec, newState, new Map()), emptyState);
41
+ }
42
+ finally {
43
+ await states.close();
44
+ await root.close();
45
+ }
46
+ });
47
+ it("reads large values back from disk", async () => {
48
+ const root = await FjallRoot.open(tmpDir, { ephemeral: true });
49
+ const states = await FjallStates.open(spec, blake2b, root);
50
+ try {
51
+ const big1 = BytesBlob.blobFromString("x".repeat(100));
52
+ const big2 = BytesBlob.blobFromString("y".repeat(100));
53
+ const key1 = Bytes.fill(HASH_SIZE, 1).asOpaque();
54
+ const key2 = Bytes.fill(HASH_SIZE, 2).asOpaque();
55
+ const entries = StateEntries.fromEntriesUnsafe([
56
+ [key1, big1],
57
+ [key2, big2],
58
+ ]);
59
+ deepEqual(await states.insertInitialState(headerHash, entries), Result.ok(OK));
60
+ const state = states.getState(headerHash);
61
+ assert.ok(state !== null);
62
+ assert.strictEqual(`${state.backend.get(key2)}`, `${big2}`);
63
+ assert.strictEqual(`${state.backend.get(key1)}`, `${big1}`);
64
+ }
65
+ finally {
66
+ await states.close();
67
+ await root.close();
68
+ }
69
+ });
70
+ it("updates state", async () => {
71
+ const root = await FjallRoot.open(tmpDir, { ephemeral: true });
72
+ const states = await FjallStates.open(spec, blake2b, root);
73
+ try {
74
+ const state = InMemoryState.empty(spec);
75
+ await states.insertInitialState(headerHash, StateEntries.serializeInMemory(spec, blake2b, state));
76
+ const newState = states.getState(headerHash);
77
+ assert.ok(newState !== null);
78
+ const headerHash2 = Bytes.fill(HASH_SIZE, 2).asOpaque();
79
+ const lookupHistory = LookupHistoryItem.new(Bytes.fill(HASH_SIZE, 0xff).asOpaque(), tryAsU32(5), tryAsLookupHistorySlots([]));
80
+ const stateUpdate = {
81
+ timeslot: tryAsTimeSlot(15),
82
+ privilegedServices: PrivilegedServices.create({
83
+ manager: tryAsServiceId(1),
84
+ assigners: tryAsPerCore(new Array(spec.coresCount).fill(tryAsServiceId(2)), spec),
85
+ delegator: tryAsServiceId(3),
86
+ registrar: tryAsServiceId(4),
87
+ autoAccumulateServices: new Map(),
88
+ }),
89
+ updated: new Map([
90
+ [
91
+ tryAsServiceId(1),
92
+ UpdateService.create({
93
+ serviceInfo: ServiceAccountInfo.create({
94
+ codeHash: Bytes.zero(HASH_SIZE).asOpaque(),
95
+ balance: tryAsU64(1_000_000),
96
+ accumulateMinGas: tryAsServiceGas(10_000),
97
+ onTransferMinGas: tryAsServiceGas(5_000),
98
+ storageUtilisationBytes: tryAsU64(1_000),
99
+ gratisStorage: tryAsU64(0),
100
+ storageUtilisationCount: tryAsU32(1),
101
+ created: tryAsTimeSlot(0),
102
+ lastAccumulation: tryAsTimeSlot(0),
103
+ parentService: tryAsServiceId(0),
104
+ }),
105
+ lookupHistory,
106
+ }),
107
+ ],
108
+ ]),
109
+ };
110
+ deepEqual(state.applyUpdate(stateUpdate), Result.ok(OK));
111
+ deepEqual(await states.updateAndSetState(headerHash2, newState, stateUpdate), Result.ok(OK));
112
+ const updatedState = states.getState(headerHash2);
113
+ assert.ok(updatedState !== null);
114
+ deepEqual(InMemoryState.copyFrom(spec, updatedState, new Map([
115
+ [
116
+ tryAsServiceId(1),
117
+ {
118
+ storageKeys: [],
119
+ preimages: [],
120
+ lookupHistory: [{ hash: lookupHistory.hash, length: lookupHistory.length }],
121
+ },
122
+ ],
123
+ ])), state);
124
+ assert.strictEqual(`${await states.getStateRoot(updatedState)}`, `${StateEntries.serializeInMemory(spec, blake2b, state).getRootHash(blake2b)}`);
125
+ }
126
+ finally {
127
+ await states.close();
128
+ await root.close();
129
+ }
130
+ });
131
+ it("imports a more complex state", async () => {
132
+ const root = await FjallRoot.open(tmpDir, { ephemeral: true });
133
+ const states = await FjallStates.open(spec, blake2b, root);
134
+ try {
135
+ const initialState = testState();
136
+ const initialService = initialState.services.get(tryAsServiceId(0));
137
+ assert.ok(initialService !== undefined);
138
+ const serialized = StateEntries.serializeInMemory(spec, blake2b, initialState);
139
+ deepEqual(await states.insertInitialState(headerHash, serialized), Result.ok(OK));
140
+ const newState = states.getState(headerHash);
141
+ assert.ok(newState !== null);
142
+ assert.strictEqual(`${await states.getStateRoot(newState)}`, `${serialized.getRootHash(blake2b)}`);
143
+ deepEqual(InMemoryState.copyFrom(spec, newState, new Map([[initialService.serviceId, initialService.getEntries()]])), initialState);
144
+ }
145
+ finally {
146
+ await states.close();
147
+ await root.close();
148
+ }
149
+ });
150
+ });
@@ -2,12 +2,14 @@
2
2
  import assert from "node:assert";
3
3
  import * as fs from "node:fs";
4
4
  import { afterEach, before, beforeEach, describe, it } from "node:test";
5
+ import { tryAsServiceId } from "#@typeberry/block";
5
6
  import { Bytes, BytesBlob } from "#@typeberry/bytes";
6
7
  import { tinyChainSpec } from "#@typeberry/config";
7
8
  import { Blake2b, HASH_SIZE } from "#@typeberry/hash";
8
- import { InMemoryState } from "#@typeberry/state";
9
+ import { InMemoryState, StorageItem, UpdateStorage, } from "#@typeberry/state";
10
+ import { testState } from "#@typeberry/state/test.utils.js";
9
11
  import { StateEntries } from "#@typeberry/state-merkleization";
10
- import { deepEqual, OK, Result } from "#@typeberry/utils";
12
+ import { asOpaqueType, deepEqual, OK, Result } from "#@typeberry/utils";
11
13
  import { HybridSerializedStates } from "./hybrid-states.js";
12
14
  let blake2b;
13
15
  before(async () => {
@@ -86,3 +88,93 @@ describe("Hybrid serialized states", () => {
86
88
  }
87
89
  });
88
90
  });
91
+ function hh(n) {
92
+ return Bytes.fill(HASH_SIZE, n).asOpaque();
93
+ }
94
+ const storageKey = asOpaqueType(BytesBlob.blobFromString("test-key"));
95
+ /** A state update writing a single large (non-embedded) value under `storageKey`. */
96
+ function storageUpdate(value) {
97
+ const item = StorageItem.create({ key: storageKey, value: BytesBlob.blobFromString(value) });
98
+ return {
99
+ storage: new Map([[tryAsServiceId(0), [UpdateStorage.set({ storage: item })]]]),
100
+ };
101
+ }
102
+ // > 32 bytes => stored in the values db (not embedded in the leaf).
103
+ const BIG_1 = "a".repeat(100);
104
+ const BIG_2 = "b".repeat(100);
105
+ /** `true` if every value referenced by the state can still be resolved. */
106
+ function canReadFully(state) {
107
+ if (state === null) {
108
+ return false;
109
+ }
110
+ try {
111
+ state.backend.intoStateEntries();
112
+ return true;
113
+ }
114
+ catch {
115
+ return false;
116
+ }
117
+ }
118
+ /** Value removals are queued, so poll for the expected outcome. */
119
+ async function eventually(check, what, timeoutMs = 5_000) {
120
+ const start = Date.now();
121
+ while (!check()) {
122
+ if (Date.now() - start > timeoutMs) {
123
+ assert.fail(`Timed out waiting for: ${what}`);
124
+ }
125
+ await new Promise((resolve) => setTimeout(resolve, 10));
126
+ }
127
+ }
128
+ describe("Hybrid serialized states value refcounting", () => {
129
+ const spec = tinyChainSpec;
130
+ let dbPath = "";
131
+ beforeEach(() => {
132
+ dbPath = createTempDir();
133
+ });
134
+ afterEach(() => {
135
+ fs.rmSync(dbPath, { recursive: true });
136
+ });
137
+ it("removes a replaced value from disk once the replacement finalizes", async () => {
138
+ const states = HybridSerializedStates.new({ spec, blake2b, dbPath });
139
+ try {
140
+ await states.insertInitialState(hh(0), StateEntries.serializeInMemory(spec, blake2b, testState()));
141
+ const s0 = states.getState(hh(0));
142
+ assert.ok(s0 !== null);
143
+ await states.updateAndSetState(hh(1), s0, storageUpdate(BIG_1));
144
+ // a handle to the post-1 state, surviving the pruning below
145
+ const stale1 = states.getState(hh(1));
146
+ const s1 = states.getState(hh(1));
147
+ assert.ok(s1 !== null);
148
+ await states.updateAndSetState(hh(2), s1, storageUpdate(BIG_2));
149
+ states.commitFinalized([hh(1)]);
150
+ assert.ok(canReadFully(stale1), "still referenced by the finalized tip");
151
+ states.commitFinalized([hh(2)]);
152
+ await eventually(() => !canReadFully(stale1), "replaced value removed from lmdb");
153
+ assert.ok(canReadFully(states.getState(hh(2))), "the new finalized tip stays fully readable");
154
+ }
155
+ finally {
156
+ await states.close();
157
+ }
158
+ });
159
+ it("collects values of a pruned dead fork and keeps the surviving chain intact", async () => {
160
+ const states = HybridSerializedStates.new({ spec, blake2b, dbPath });
161
+ try {
162
+ await states.insertInitialState(hh(0), StateEntries.serializeInMemory(spec, blake2b, testState()));
163
+ const s0 = states.getState(hh(0));
164
+ assert.ok(s0 !== null);
165
+ await states.updateAndSetState(hh(1), s0, storageUpdate(BIG_1));
166
+ // a dead fork on top of genesis, inserting a different value
167
+ const fork = states.getState(hh(0));
168
+ assert.ok(fork !== null);
169
+ await states.updateAndSetState(hh(0xaa), fork, storageUpdate(BIG_2));
170
+ const staleFork = states.getState(hh(0xaa));
171
+ states.markUnused(hh(0xaa));
172
+ assert.strictEqual(states.getState(hh(0xaa)), null);
173
+ await eventually(() => !canReadFully(staleFork), "fork-only value removed from lmdb");
174
+ assert.ok(canReadFully(states.getState(hh(1))), "surviving chain is unaffected");
175
+ }
176
+ finally {
177
+ await states.close();
178
+ }
179
+ });
180
+ });