@bitsocial/pubsub-votes 0.0.2
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.
- package/LICENSE +674 -0
- package/README.md +212 -0
- package/dist/chain/bucket.d.ts +13 -0
- package/dist/chain/bucket.js +24 -0
- package/dist/chain/ticker.d.ts +15 -0
- package/dist/chain/ticker.js +25 -0
- package/dist/chain/types.d.ts +90 -0
- package/dist/chain/types.js +1 -0
- package/dist/checkpoint/codec.d.ts +54 -0
- package/dist/checkpoint/codec.js +99 -0
- package/dist/client/root-puller.d.ts +49 -0
- package/dist/client/root-puller.js +140 -0
- package/dist/client/voter.d.ts +225 -0
- package/dist/client/voter.js +1195 -0
- package/dist/crdt/codec.d.ts +41 -0
- package/dist/crdt/codec.js +137 -0
- package/dist/crdt/crdt.d.ts +22 -0
- package/dist/crdt/crdt.js +127 -0
- package/dist/crdt/store.d.ts +8 -0
- package/dist/crdt/store.js +23 -0
- package/dist/crdt/types.d.ts +87 -0
- package/dist/crdt/types.js +1 -0
- package/dist/encoding/canonical.d.ts +22 -0
- package/dist/encoding/canonical.js +26 -0
- package/dist/errors.d.ts +72 -0
- package/dist/errors.js +111 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.js +39 -0
- package/dist/rules/constant.d.ts +14 -0
- package/dist/rules/constant.js +18 -0
- package/dist/rules/erc20-balance.d.ts +30 -0
- package/dist/rules/erc20-balance.js +44 -0
- package/dist/rules/erc721-min-balance.d.ts +18 -0
- package/dist/rules/erc721-min-balance.js +56 -0
- package/dist/rules/registry.d.ts +42 -0
- package/dist/rules/registry.js +61 -0
- package/dist/rules/types.d.ts +74 -0
- package/dist/rules/types.js +1 -0
- package/dist/schema/common.d.ts +25 -0
- package/dist/schema/common.js +24 -0
- package/dist/schema/criteria.d.ts +78 -0
- package/dist/schema/criteria.js +76 -0
- package/dist/schema/directory.d.ts +42 -0
- package/dist/schema/directory.js +52 -0
- package/dist/schema/votes.d.ts +55 -0
- package/dist/schema/votes.js +116 -0
- package/dist/signer/eip712.d.ts +103 -0
- package/dist/signer/eip712.js +85 -0
- package/dist/signer/types.d.ts +31 -0
- package/dist/signer/types.js +1 -0
- package/dist/storage/browser.d.ts +3 -0
- package/dist/storage/browser.js +110 -0
- package/dist/storage/memory.d.ts +10 -0
- package/dist/storage/memory.js +56 -0
- package/dist/storage/node.d.ts +5 -0
- package/dist/storage/node.js +106 -0
- package/dist/storage/types.d.ts +46 -0
- package/dist/storage/types.js +1 -0
- package/dist/store/indexeddb.d.ts +9 -0
- package/dist/store/indexeddb.js +72 -0
- package/dist/store/memory.d.ts +15 -0
- package/dist/store/memory.js +22 -0
- package/dist/store/select.d.ts +15 -0
- package/dist/store/select.js +64 -0
- package/dist/store/sqlite.d.ts +11 -0
- package/dist/store/sqlite.js +68 -0
- package/dist/store/types.d.ts +57 -0
- package/dist/store/types.js +1 -0
- package/dist/tally/tally.d.ts +44 -0
- package/dist/tally/tally.js +89 -0
- package/dist/tally/types.d.ts +51 -0
- package/dist/tally/types.js +13 -0
- package/dist/topic.d.ts +20 -0
- package/dist/topic.js +28 -0
- package/dist/transport/accepted-dedup.d.ts +30 -0
- package/dist/transport/accepted-dedup.js +34 -0
- package/dist/transport/announce/browser.d.ts +9 -0
- package/dist/transport/announce/browser.js +14 -0
- package/dist/transport/announce/node.d.ts +38 -0
- package/dist/transport/announce/node.js +162 -0
- package/dist/transport/announce/types.d.ts +74 -0
- package/dist/transport/announce/types.js +16 -0
- package/dist/transport/bundle-store.d.ts +11 -0
- package/dist/transport/bundle-store.js +34 -0
- package/dist/transport/chase.d.ts +83 -0
- package/dist/transport/chase.js +88 -0
- package/dist/transport/gossip-validator.d.ts +107 -0
- package/dist/transport/gossip-validator.js +99 -0
- package/dist/transport/helia.d.ts +41 -0
- package/dist/transport/helia.js +98 -0
- package/dist/transport/integration/harness.d.ts +60 -0
- package/dist/transport/integration/harness.js +262 -0
- package/dist/transport/messages.d.ts +97 -0
- package/dist/transport/messages.js +117 -0
- package/dist/transport/rate-limit.d.ts +11 -0
- package/dist/transport/rate-limit.js +20 -0
- package/dist/transport/transport.d.ts +20 -0
- package/dist/transport/transport.js +35 -0
- package/dist/transport/types.d.ts +165 -0
- package/dist/transport/types.js +1 -0
- package/dist/verify/background.d.ts +81 -0
- package/dist/verify/background.js +236 -0
- package/dist/verify/bundle.d.ts +58 -0
- package/dist/verify/bundle.js +84 -0
- package/dist/verify/cache.d.ts +48 -0
- package/dist/verify/cache.js +62 -0
- package/dist/verify/constraints.d.ts +16 -0
- package/dist/verify/constraints.js +35 -0
- package/dist/verify/gate-result-cache.d.ts +65 -0
- package/dist/verify/gate-result-cache.js +91 -0
- package/dist/verify/name-resolution-cache.d.ts +59 -0
- package/dist/verify/name-resolution-cache.js +64 -0
- package/dist/verify/signature.d.ts +9 -0
- package/dist/verify/signature.js +55 -0
- package/dist/verify/types.d.ts +101 -0
- package/dist/verify/types.js +1 -0
- package/package.json +77 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import localForage from "localforage";
|
|
2
|
+
import { makeMemoryStorage } from "./memory.js";
|
|
3
|
+
/**
|
|
4
|
+
* The browser backend, swapped in for storage/node.ts by the package.json `browser` field
|
|
5
|
+
* remap (same mechanism pkc-js uses for its whole runtime tree, scoped here to one module).
|
|
6
|
+
* It is a port of pkc-js's localforage-lru (runtime/browser/localforage-lru.ts): two
|
|
7
|
+
* IndexedDB databases per named cache (`pubsub-votes-{cacheName}` and `…2`) with round-robin
|
|
8
|
+
* eviction — writes fill the active database, and when it reaches `maxItems` the databases
|
|
9
|
+
* swap roles and the new active one is cleared, so at most the oldest half of entries drops
|
|
10
|
+
* at once. A read hit in the inactive database promotes the entry to the active one, which is
|
|
11
|
+
* what makes the scheme approximate LRU rather than plain FIFO.
|
|
12
|
+
*
|
|
13
|
+
* `dataPath` is meaningless in the browser (pkc-js's browser `getDefaultDataPath()` is
|
|
14
|
+
* likewise `undefined`): a string is ignored, `false` still selects the in-memory backend.
|
|
15
|
+
*/
|
|
16
|
+
class LocalForageLruStorage {
|
|
17
|
+
#maxItems;
|
|
18
|
+
#name;
|
|
19
|
+
#initialized;
|
|
20
|
+
constructor(opts) {
|
|
21
|
+
this.#name = `pubsub-votes-${opts.cacheName}`;
|
|
22
|
+
this.#maxItems = opts.maxItems;
|
|
23
|
+
}
|
|
24
|
+
#init() {
|
|
25
|
+
this.#initialized ??= (async () => {
|
|
26
|
+
const database1 = localForage.createInstance({ name: this.#name });
|
|
27
|
+
const database2 = localForage.createInstance({ name: `${this.#name}2` });
|
|
28
|
+
const [size1, size2] = await Promise.all([database1.length(), database2.length()]);
|
|
29
|
+
// The largest database is the active one, unless it is full (a full database is
|
|
30
|
+
// always the inactive, about-to-be-dropped half) — pkc-js's exact recovery rule.
|
|
31
|
+
let [active, inactive] = (size1 >= size2 && size1 !== this.#maxItems) || size2 === this.#maxItems
|
|
32
|
+
? [database1, database2]
|
|
33
|
+
: [database2, database1];
|
|
34
|
+
let activeSize = await active.length();
|
|
35
|
+
const swap = async () => {
|
|
36
|
+
activeSize = 0;
|
|
37
|
+
[active, inactive] = [inactive, active];
|
|
38
|
+
await active.clear();
|
|
39
|
+
};
|
|
40
|
+
return {
|
|
41
|
+
active: () => active,
|
|
42
|
+
inactive: () => inactive,
|
|
43
|
+
grow: async () => {
|
|
44
|
+
activeSize += 1;
|
|
45
|
+
if (activeSize >= this.#maxItems) {
|
|
46
|
+
// removeItem/clear never decrement the counter, so it only ever
|
|
47
|
+
// over-counts — reconcile at the threshold so a purge-heavy session
|
|
48
|
+
// cannot swap (and clear) before the active half is actually full.
|
|
49
|
+
activeSize = await active.length();
|
|
50
|
+
if (activeSize >= this.#maxItems)
|
|
51
|
+
await swap();
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
swap
|
|
55
|
+
};
|
|
56
|
+
})();
|
|
57
|
+
return this.#initialized;
|
|
58
|
+
}
|
|
59
|
+
async getItem(key) {
|
|
60
|
+
const db = await this.#init();
|
|
61
|
+
const value = await db.active().getItem(key);
|
|
62
|
+
if (value !== null && value !== undefined)
|
|
63
|
+
return value;
|
|
64
|
+
const older = await db.inactive().getItem(key);
|
|
65
|
+
if (older === null || older === undefined)
|
|
66
|
+
return undefined;
|
|
67
|
+
await this.setItem(key, older); // promote: a hit in the older half re-enters the active one
|
|
68
|
+
return older;
|
|
69
|
+
}
|
|
70
|
+
async setItem(key, value) {
|
|
71
|
+
const db = await this.#init();
|
|
72
|
+
const existing = await db.active().getItem(key);
|
|
73
|
+
await db.active().setItem(key, value ?? null);
|
|
74
|
+
if (existing === null || existing === undefined)
|
|
75
|
+
await db.grow();
|
|
76
|
+
}
|
|
77
|
+
async removeItem(key) {
|
|
78
|
+
const db = await this.#init();
|
|
79
|
+
await Promise.all([db.active().removeItem(key), db.inactive().removeItem(key)]);
|
|
80
|
+
}
|
|
81
|
+
async keys() {
|
|
82
|
+
const db = await this.#init();
|
|
83
|
+
const [keys1, keys2] = await Promise.all([db.active().keys(), db.inactive().keys()]);
|
|
84
|
+
return [...new Set([...keys1, ...keys2])];
|
|
85
|
+
}
|
|
86
|
+
async clear() {
|
|
87
|
+
const db = await this.#init();
|
|
88
|
+
await Promise.all([db.active().clear(), db.inactive().clear()]);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** Build the browser {@link VoteStorage}: IndexedDB via localforage, or in-memory for `false`. */
|
|
92
|
+
export function makeStorage(options) {
|
|
93
|
+
if (options.dataPath === false)
|
|
94
|
+
return makeMemoryStorage();
|
|
95
|
+
const stores = new Map();
|
|
96
|
+
return {
|
|
97
|
+
openLru({ cacheName, maxItems }) {
|
|
98
|
+
let store = stores.get(cacheName);
|
|
99
|
+
if (!store) {
|
|
100
|
+
store = new LocalForageLruStorage({ cacheName, maxItems });
|
|
101
|
+
stores.set(cacheName, store);
|
|
102
|
+
}
|
|
103
|
+
return store;
|
|
104
|
+
},
|
|
105
|
+
// localforage holds no closeable handles; dropping the references is the whole teardown.
|
|
106
|
+
async destroy() {
|
|
107
|
+
stores.clear();
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { LruStorage, VoteStorage } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* The `dataPath: false` backend on both platforms (pkc-js's `noData` equivalent): a true-LRU
|
|
4
|
+
* `Map` — a `get` hit re-inserts the key so insertion order IS recency order, and eviction
|
|
5
|
+
* deletes the oldest key past `maxItems`. Nothing survives the process; semantics otherwise
|
|
6
|
+
* match the persistent backends so tests and no-disk hosts exercise the same code paths.
|
|
7
|
+
*/
|
|
8
|
+
export declare function makeMemoryLruStorage(maxItems: number): LruStorage;
|
|
9
|
+
/** An all-in-memory {@link VoteStorage}; `destroy()` only drops the references. */
|
|
10
|
+
export declare function makeMemoryStorage(): VoteStorage;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `dataPath: false` backend on both platforms (pkc-js's `noData` equivalent): a true-LRU
|
|
3
|
+
* `Map` — a `get` hit re-inserts the key so insertion order IS recency order, and eviction
|
|
4
|
+
* deletes the oldest key past `maxItems`. Nothing survives the process; semantics otherwise
|
|
5
|
+
* match the persistent backends so tests and no-disk hosts exercise the same code paths.
|
|
6
|
+
*/
|
|
7
|
+
export function makeMemoryLruStorage(maxItems) {
|
|
8
|
+
const entries = new Map();
|
|
9
|
+
const touch = (key, value) => {
|
|
10
|
+
entries.delete(key);
|
|
11
|
+
entries.set(key, value);
|
|
12
|
+
};
|
|
13
|
+
return {
|
|
14
|
+
async getItem(key) {
|
|
15
|
+
if (!entries.has(key))
|
|
16
|
+
return undefined;
|
|
17
|
+
const value = entries.get(key);
|
|
18
|
+
touch(key, value);
|
|
19
|
+
return value;
|
|
20
|
+
},
|
|
21
|
+
async setItem(key, value) {
|
|
22
|
+
touch(key, value);
|
|
23
|
+
if (entries.size > maxItems) {
|
|
24
|
+
const oldest = entries.keys().next().value;
|
|
25
|
+
if (oldest !== undefined)
|
|
26
|
+
entries.delete(oldest);
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
async removeItem(key) {
|
|
30
|
+
entries.delete(key);
|
|
31
|
+
},
|
|
32
|
+
async keys() {
|
|
33
|
+
return [...entries.keys()];
|
|
34
|
+
},
|
|
35
|
+
async clear() {
|
|
36
|
+
entries.clear();
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** An all-in-memory {@link VoteStorage}; `destroy()` only drops the references. */
|
|
41
|
+
export function makeMemoryStorage() {
|
|
42
|
+
const stores = new Map();
|
|
43
|
+
return {
|
|
44
|
+
openLru({ cacheName, maxItems }) {
|
|
45
|
+
let store = stores.get(cacheName);
|
|
46
|
+
if (!store) {
|
|
47
|
+
store = makeMemoryLruStorage(maxItems);
|
|
48
|
+
stores.set(cacheName, store);
|
|
49
|
+
}
|
|
50
|
+
return store;
|
|
51
|
+
},
|
|
52
|
+
async destroy() {
|
|
53
|
+
stores.clear();
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { StorageOptions, VoteStorage } from "./types.js";
|
|
2
|
+
/** Node's default data path when the host passes none (see {@link StorageOptions.dataPath}). */
|
|
3
|
+
export declare function defaultDataPath(): string;
|
|
4
|
+
/** Build the Node {@link VoteStorage}: sqlite under the data path, or in-memory for `false`. */
|
|
5
|
+
export declare function makeStorage(options: StorageOptions): VoteStorage;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import { mkdirSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { makeMemoryStorage } from "./memory.js";
|
|
5
|
+
/**
|
|
6
|
+
* The Node backend: one better-sqlite3 database per named cache under
|
|
7
|
+
* `{dataPath}/lru-storage/{cacheName}.db`, a port of pkc-js's `SqliteCache`
|
|
8
|
+
* (runtime/node/sqlite-lru-cache.ts) with the same table shape and LRU rule — `lastAccess`
|
|
9
|
+
* updated on every hit, eviction deletes everything past `maxItems` in recency order. Ports
|
|
10
|
+
* differ in three deliberate ways: values are JSON text (not cbor blobs — callers already
|
|
11
|
+
* stringify, and it keeps this file dependency-free beyond the driver), there is no TTL
|
|
12
|
+
* column (freshness here is read-time policy: the name cache checks `resolvedAtMs` against a
|
|
13
|
+
* per-call max-age, the gate store is purged by sample-block expiry), and LRU cleanup runs
|
|
14
|
+
* inline on each write instead of on a timer (writes are per wallet-bucket / per name — rare
|
|
15
|
+
* — and no timer means nothing to keep the process alive or `unref`).
|
|
16
|
+
*
|
|
17
|
+
* WAL mode matches pkc-js: it reduces the lost-write window when two processes share a
|
|
18
|
+
* dataPath. The database opens lazily on first use, so constructing a voter touches no disk.
|
|
19
|
+
*/
|
|
20
|
+
class SqliteLruStorage {
|
|
21
|
+
#db;
|
|
22
|
+
#file;
|
|
23
|
+
#dir;
|
|
24
|
+
#maxItems;
|
|
25
|
+
#statements;
|
|
26
|
+
constructor(opts) {
|
|
27
|
+
this.#dir = opts.dir;
|
|
28
|
+
this.#file = join(opts.dir, `${opts.cacheName}.db`);
|
|
29
|
+
this.#maxItems = opts.maxItems;
|
|
30
|
+
}
|
|
31
|
+
#closed = false;
|
|
32
|
+
#open() {
|
|
33
|
+
if (this.#statements)
|
|
34
|
+
return this.#statements;
|
|
35
|
+
// A late fire-and-forget write after destroy() must fail (callers swallow cache
|
|
36
|
+
// errors), never silently re-create the database file.
|
|
37
|
+
if (this.#closed)
|
|
38
|
+
throw new Error("storage is closed");
|
|
39
|
+
mkdirSync(this.#dir, { recursive: true });
|
|
40
|
+
const db = new Database(this.#file);
|
|
41
|
+
db.pragma("journal_mode = WAL");
|
|
42
|
+
db.prepare(`CREATE TABLE IF NOT EXISTS cache (key TEXT PRIMARY KEY, value TEXT, lastAccess INT)`).run();
|
|
43
|
+
db.prepare(`CREATE INDEX IF NOT EXISTS lastAccess ON cache (lastAccess)`).run();
|
|
44
|
+
this.#db = db;
|
|
45
|
+
this.#statements = {
|
|
46
|
+
get: db.prepare(`UPDATE OR IGNORE cache SET lastAccess = @now WHERE key = @key RETURNING value`),
|
|
47
|
+
set: db.prepare(`INSERT OR REPLACE INTO cache (key, value, lastAccess) VALUES (@key, @value, @now)`),
|
|
48
|
+
remove: db.prepare(`DELETE FROM cache WHERE key = @key`),
|
|
49
|
+
keys: db.prepare(`SELECT key FROM cache`),
|
|
50
|
+
clear: db.prepare(`DELETE FROM cache`),
|
|
51
|
+
evict: db.prepare(`WITH lru AS (SELECT key FROM cache ORDER BY lastAccess DESC LIMIT -1 OFFSET @maxItems)
|
|
52
|
+
DELETE FROM cache WHERE key IN lru`)
|
|
53
|
+
};
|
|
54
|
+
return this.#statements;
|
|
55
|
+
}
|
|
56
|
+
async getItem(key) {
|
|
57
|
+
const row = this.#open().get.get({ key, now: Date.now() });
|
|
58
|
+
return row === undefined ? undefined : JSON.parse(row.value);
|
|
59
|
+
}
|
|
60
|
+
async setItem(key, value) {
|
|
61
|
+
const statements = this.#open();
|
|
62
|
+
statements.set.run({ key, value: JSON.stringify(value ?? null), now: Date.now() });
|
|
63
|
+
statements.evict.run({ maxItems: this.#maxItems });
|
|
64
|
+
}
|
|
65
|
+
async removeItem(key) {
|
|
66
|
+
this.#open().remove.run({ key });
|
|
67
|
+
}
|
|
68
|
+
async keys() {
|
|
69
|
+
return this.#open().keys.all().map((row) => row.key);
|
|
70
|
+
}
|
|
71
|
+
async clear() {
|
|
72
|
+
this.#open().clear.run();
|
|
73
|
+
}
|
|
74
|
+
close() {
|
|
75
|
+
this.#closed = true;
|
|
76
|
+
this.#db?.close();
|
|
77
|
+
this.#db = undefined;
|
|
78
|
+
this.#statements = undefined;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** Node's default data path when the host passes none (see {@link StorageOptions.dataPath}). */
|
|
82
|
+
export function defaultDataPath() {
|
|
83
|
+
return join(process.cwd(), ".bitsocial-pubsub-votes");
|
|
84
|
+
}
|
|
85
|
+
/** Build the Node {@link VoteStorage}: sqlite under the data path, or in-memory for `false`. */
|
|
86
|
+
export function makeStorage(options) {
|
|
87
|
+
if (options.dataPath === false)
|
|
88
|
+
return makeMemoryStorage();
|
|
89
|
+
const dir = join(options.dataPath ?? defaultDataPath(), "lru-storage");
|
|
90
|
+
const stores = new Map();
|
|
91
|
+
return {
|
|
92
|
+
openLru({ cacheName, maxItems }) {
|
|
93
|
+
let store = stores.get(cacheName);
|
|
94
|
+
if (!store) {
|
|
95
|
+
store = new SqliteLruStorage({ dir, cacheName, maxItems });
|
|
96
|
+
stores.set(cacheName, store);
|
|
97
|
+
}
|
|
98
|
+
return store;
|
|
99
|
+
},
|
|
100
|
+
async destroy() {
|
|
101
|
+
for (const store of stores.values())
|
|
102
|
+
store.close();
|
|
103
|
+
stores.clear();
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The persistent-cache seam, mirroring pkc-js's LRU storage tier (`LRUStorageInterface` /
|
|
3
|
+
* `_createStorageLRU`): named, bounded, LRU-evicting key-value stores under the voter's data
|
|
4
|
+
* path. On Node the backing is one better-sqlite3 database per cache under
|
|
5
|
+
* `{dataPath}/lru-storage/` (storage/node.ts); in the browser it is localforage (IndexedDB)
|
|
6
|
+
* with pkc-js's dual-instance round-robin eviction (storage/browser.ts — bundlers swap the
|
|
7
|
+
* module via the package.json `browser` field remap); `dataPath: false` selects the in-memory
|
|
8
|
+
* backend on both (storage/memory.ts). Values must be JSON-serializable — callers stringify
|
|
9
|
+
* anything richer (a gate score `bigint` travels as a decimal string).
|
|
10
|
+
*
|
|
11
|
+
* Deliberate deviation from pkc-js's interface: no explicit `init()` — every store
|
|
12
|
+
* self-initializes lazily on first use, so opening a store is synchronous and cheap and the
|
|
13
|
+
* voter constructor stays non-async.
|
|
14
|
+
*/
|
|
15
|
+
export interface LruStorage {
|
|
16
|
+
/** The stored value, or `undefined` on a miss. A hit refreshes the entry's LRU position. */
|
|
17
|
+
getItem(key: string): Promise<unknown>;
|
|
18
|
+
/** Insert or replace. May evict the least-recently-used entry past the store's `maxItems`. */
|
|
19
|
+
setItem(key: string, value: unknown): Promise<void>;
|
|
20
|
+
removeItem(key: string): Promise<void>;
|
|
21
|
+
/** Every live key (unordered). Used for the gate store's deterministic expiry purge. */
|
|
22
|
+
keys(): Promise<string[]>;
|
|
23
|
+
clear(): Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
/** One voter's persistent-cache root: opens named LRU stores, closed as a unit on `destroy()`. */
|
|
26
|
+
export interface VoteStorage {
|
|
27
|
+
/**
|
|
28
|
+
* Open (or return the already-open) named store. Names are fixed library-internal slugs
|
|
29
|
+
* (`gate-results`, `name-resolutions`), never user input.
|
|
30
|
+
*/
|
|
31
|
+
openLru(opts: {
|
|
32
|
+
cacheName: string;
|
|
33
|
+
maxItems: number;
|
|
34
|
+
}): LruStorage;
|
|
35
|
+
/** Close every open store (Node: close the sqlite handles). Terminal, like `PubsubVoter.destroy`. */
|
|
36
|
+
destroy(): Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* `PubsubVoterOptions.dataPath`, resolved: a directory for persistent caches, or `false` for
|
|
40
|
+
* in-memory-only (no disk, no IndexedDB — the pkc-js `noData` equivalent). `undefined` picks
|
|
41
|
+
* the platform default: `{cwd}/.bitsocial-pubsub-votes` on Node, named IndexedDB databases in
|
|
42
|
+
* the browser (where a path is meaningless and ignored).
|
|
43
|
+
*/
|
|
44
|
+
export interface StorageOptions {
|
|
45
|
+
dataPath?: string | false | undefined;
|
|
46
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { VoteIntent, VoteStore } from "./types.js";
|
|
2
|
+
export declare class IndexedDbVoteStore implements VoteStore {
|
|
3
|
+
#private;
|
|
4
|
+
list(): Promise<VoteIntent[]>;
|
|
5
|
+
get(topic: string): Promise<VoteIntent | undefined>;
|
|
6
|
+
put(intent: VoteIntent): Promise<void>;
|
|
7
|
+
delete(topic: string): Promise<void>;
|
|
8
|
+
destroy(): Promise<void>;
|
|
9
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { VoteSchema } from "../schema/votes.js";
|
|
3
|
+
/**
|
|
4
|
+
* The browser {@link VoteStore} backend: this wallet's re-signable vote intents in an
|
|
5
|
+
* IndexedDB object store, so republishing survives a page reload (see DESIGN.md "Persistence").
|
|
6
|
+
* It holds only *this* voter's choices — never the CRDT of everyone's bundles.
|
|
7
|
+
*
|
|
8
|
+
* Selected by `selectVoteStore` when a global `indexedDB` is present (the browser); Node uses
|
|
9
|
+
* the SQLite backend instead. No third-party dependency — plain IndexedDB behind small
|
|
10
|
+
* promise wrappers. One record per contest, keyed by `topic`; each record is re-validated
|
|
11
|
+
* through {@link VoteSchema} on read so a corrupt entry cannot smuggle a malformed vote back
|
|
12
|
+
* into the signer.
|
|
13
|
+
*/
|
|
14
|
+
const DB_NAME = "bitsocial-pubsub-votes";
|
|
15
|
+
const STORE_NAME = "vote_intents";
|
|
16
|
+
/** The persisted intent shape, re-validated on read (IndexedDB returns `any`). */
|
|
17
|
+
const StoredIntentSchema = z.object({
|
|
18
|
+
topic: z.string(),
|
|
19
|
+
address: z.string(),
|
|
20
|
+
votes: z.array(VoteSchema),
|
|
21
|
+
lastBucket: z.number()
|
|
22
|
+
});
|
|
23
|
+
/** Wrap an IndexedDB request as a promise. */
|
|
24
|
+
function promisify(request) {
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
request.onsuccess = () => resolve(request.result);
|
|
27
|
+
request.onerror = () => reject(request.error);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
export class IndexedDbVoteStore {
|
|
31
|
+
#db;
|
|
32
|
+
/** Open (creating the object store on first use) the vote-intents database. */
|
|
33
|
+
async #open() {
|
|
34
|
+
if (this.#db !== undefined)
|
|
35
|
+
return this.#db;
|
|
36
|
+
const db = await new Promise((resolve, reject) => {
|
|
37
|
+
const request = indexedDB.open(DB_NAME, 1);
|
|
38
|
+
request.onupgradeneeded = () => {
|
|
39
|
+
if (!request.result.objectStoreNames.contains(STORE_NAME)) {
|
|
40
|
+
request.result.createObjectStore(STORE_NAME, { keyPath: "topic" });
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
request.onsuccess = () => resolve(request.result);
|
|
44
|
+
request.onerror = () => reject(request.error);
|
|
45
|
+
});
|
|
46
|
+
this.#db = db;
|
|
47
|
+
return db;
|
|
48
|
+
}
|
|
49
|
+
async #tx(mode, run) {
|
|
50
|
+
const db = await this.#open();
|
|
51
|
+
const store = db.transaction(STORE_NAME, mode).objectStore(STORE_NAME);
|
|
52
|
+
return run(store);
|
|
53
|
+
}
|
|
54
|
+
async list() {
|
|
55
|
+
const rows = await this.#tx("readonly", (store) => promisify(store.getAll()));
|
|
56
|
+
return rows.map((row) => StoredIntentSchema.parse(row));
|
|
57
|
+
}
|
|
58
|
+
async get(topic) {
|
|
59
|
+
const row = await this.#tx("readonly", (store) => promisify(store.get(topic)));
|
|
60
|
+
return row === undefined ? undefined : StoredIntentSchema.parse(row);
|
|
61
|
+
}
|
|
62
|
+
async put(intent) {
|
|
63
|
+
await this.#tx("readwrite", (store) => promisify(store.put(intent)));
|
|
64
|
+
}
|
|
65
|
+
async delete(topic) {
|
|
66
|
+
await this.#tx("readwrite", (store) => promisify(store.delete(topic)));
|
|
67
|
+
}
|
|
68
|
+
async destroy() {
|
|
69
|
+
this.#db?.close();
|
|
70
|
+
this.#db = undefined;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { VoteIntent, VoteStore } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* In-memory {@link VoteStore}: the fallback used on Node when no `dataPath` is given (the
|
|
4
|
+
* durable backends are the browser's IndexedDB and Node's SQLite-under-`dataPath` — see
|
|
5
|
+
* `selectVoteStore` and DESIGN.md "Persistence"). Intents live only for the lifetime of the
|
|
6
|
+
* process, so republishing does NOT survive a restart with this backend. It exists so the
|
|
7
|
+
* voter's lifecycle works with no configured persistence and so unit tests run with no I/O.
|
|
8
|
+
*/
|
|
9
|
+
export declare class MemoryVoteStore implements VoteStore {
|
|
10
|
+
#private;
|
|
11
|
+
list(): Promise<VoteIntent[]>;
|
|
12
|
+
get(topic: string): Promise<VoteIntent | undefined>;
|
|
13
|
+
put(intent: VoteIntent): Promise<void>;
|
|
14
|
+
delete(topic: string): Promise<void>;
|
|
15
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory {@link VoteStore}: the fallback used on Node when no `dataPath` is given (the
|
|
3
|
+
* durable backends are the browser's IndexedDB and Node's SQLite-under-`dataPath` — see
|
|
4
|
+
* `selectVoteStore` and DESIGN.md "Persistence"). Intents live only for the lifetime of the
|
|
5
|
+
* process, so republishing does NOT survive a restart with this backend. It exists so the
|
|
6
|
+
* voter's lifecycle works with no configured persistence and so unit tests run with no I/O.
|
|
7
|
+
*/
|
|
8
|
+
export class MemoryVoteStore {
|
|
9
|
+
#byTopic = new Map();
|
|
10
|
+
async list() {
|
|
11
|
+
return [...this.#byTopic.values()];
|
|
12
|
+
}
|
|
13
|
+
async get(topic) {
|
|
14
|
+
return this.#byTopic.get(topic);
|
|
15
|
+
}
|
|
16
|
+
async put(intent) {
|
|
17
|
+
this.#byTopic.set(intent.topic, intent);
|
|
18
|
+
}
|
|
19
|
+
async delete(topic) {
|
|
20
|
+
this.#byTopic.delete(topic);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { VoteStore } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Pick the vote store for a voter by environment:
|
|
4
|
+
* - a global `indexedDB` (the browser) → the IndexedDB backend;
|
|
5
|
+
* - otherwise Node with a `dataPath` → a **SQLite file under `dataPath`** (WAL mode, the same
|
|
6
|
+
* `dataPath` convention pkc-js and `@bitsocial/bso-resolver` use);
|
|
7
|
+
* - otherwise (Node, no `dataPath`) → in-memory (intents lost on restart).
|
|
8
|
+
*
|
|
9
|
+
* The concrete backends are imported **lazily** (dynamic `import()` inside {@link LazyVoteStore}):
|
|
10
|
+
* the Node backend pulls in the native `better-sqlite3`, which must never enter a browser bundle,
|
|
11
|
+
* and the browser backend touches `indexedDB`, absent on Node. Deferring the import to first use
|
|
12
|
+
* keeps each out of the other environment's module graph while leaving this function synchronous
|
|
13
|
+
* (the `PubsubVoter` constructor stays sync). See DESIGN.md "Persistence".
|
|
14
|
+
*/
|
|
15
|
+
export declare function selectVoteStore(dataPath: string | undefined): VoteStore;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { MemoryVoteStore } from "./memory.js";
|
|
2
|
+
/**
|
|
3
|
+
* Pick the vote store for a voter by environment:
|
|
4
|
+
* - a global `indexedDB` (the browser) → the IndexedDB backend;
|
|
5
|
+
* - otherwise Node with a `dataPath` → a **SQLite file under `dataPath`** (WAL mode, the same
|
|
6
|
+
* `dataPath` convention pkc-js and `@bitsocial/bso-resolver` use);
|
|
7
|
+
* - otherwise (Node, no `dataPath`) → in-memory (intents lost on restart).
|
|
8
|
+
*
|
|
9
|
+
* The concrete backends are imported **lazily** (dynamic `import()` inside {@link LazyVoteStore}):
|
|
10
|
+
* the Node backend pulls in the native `better-sqlite3`, which must never enter a browser bundle,
|
|
11
|
+
* and the browser backend touches `indexedDB`, absent on Node. Deferring the import to first use
|
|
12
|
+
* keeps each out of the other environment's module graph while leaving this function synchronous
|
|
13
|
+
* (the `PubsubVoter` constructor stays sync). See DESIGN.md "Persistence".
|
|
14
|
+
*/
|
|
15
|
+
export function selectVoteStore(dataPath) {
|
|
16
|
+
if (typeof indexedDB !== "undefined") {
|
|
17
|
+
return new LazyVoteStore(async () => {
|
|
18
|
+
const { IndexedDbVoteStore } = await import("./indexeddb.js");
|
|
19
|
+
return new IndexedDbVoteStore();
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
if (dataPath !== undefined) {
|
|
23
|
+
return new LazyVoteStore(async () => {
|
|
24
|
+
const { SqliteVoteStore } = await import("./sqlite.js");
|
|
25
|
+
return new SqliteVoteStore(dataPath);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
return new MemoryVoteStore();
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* A {@link VoteStore} that defers constructing its backend until the first method call, so the
|
|
32
|
+
* backend module (and its environment-specific dependency) is only `import()`ed when actually
|
|
33
|
+
* used. The factory runs at most once; every method delegates to the resolved backend.
|
|
34
|
+
*/
|
|
35
|
+
class LazyVoteStore {
|
|
36
|
+
factory;
|
|
37
|
+
#backend;
|
|
38
|
+
constructor(factory) {
|
|
39
|
+
this.factory = factory;
|
|
40
|
+
}
|
|
41
|
+
#resolve() {
|
|
42
|
+
if (this.#backend === undefined)
|
|
43
|
+
this.#backend = this.factory();
|
|
44
|
+
return this.#backend;
|
|
45
|
+
}
|
|
46
|
+
async list() {
|
|
47
|
+
return (await this.#resolve()).list();
|
|
48
|
+
}
|
|
49
|
+
async get(topic) {
|
|
50
|
+
return (await this.#resolve()).get(topic);
|
|
51
|
+
}
|
|
52
|
+
async put(intent) {
|
|
53
|
+
return (await this.#resolve()).put(intent);
|
|
54
|
+
}
|
|
55
|
+
async delete(topic) {
|
|
56
|
+
return (await this.#resolve()).delete(topic);
|
|
57
|
+
}
|
|
58
|
+
async destroy() {
|
|
59
|
+
// Nothing to release if the backend was never opened.
|
|
60
|
+
if (this.#backend === undefined)
|
|
61
|
+
return;
|
|
62
|
+
await (await this.#backend).destroy?.();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { VoteIntent, VoteStore } from "./types.js";
|
|
2
|
+
export declare class SqliteVoteStore implements VoteStore {
|
|
3
|
+
#private;
|
|
4
|
+
/** Open (creating if absent) the vote-intents DB inside the `dataPath` directory. */
|
|
5
|
+
constructor(dataPath: string);
|
|
6
|
+
list(): Promise<VoteIntent[]>;
|
|
7
|
+
get(topic: string): Promise<VoteIntent | undefined>;
|
|
8
|
+
put(intent: VoteIntent): Promise<void>;
|
|
9
|
+
delete(topic: string): Promise<void>;
|
|
10
|
+
destroy(): Promise<void>;
|
|
11
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { mkdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import Database from "better-sqlite3";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { VoteSchema } from "../schema/votes.js";
|
|
6
|
+
/**
|
|
7
|
+
* The Node {@link VoteStore} backend: this wallet's re-signable vote intents in a SQLite file
|
|
8
|
+
* under the constructor's `dataPath` directory (WAL mode), so republishing survives a process
|
|
9
|
+
* restart (see DESIGN.md "Persistence"). It holds only *this* voter's choices — never the CRDT
|
|
10
|
+
* of everyone's bundles, which lives in the host's Helia blockstore.
|
|
11
|
+
*
|
|
12
|
+
* `better-sqlite3` is a native Node module, so this file is imported **only dynamically** (via
|
|
13
|
+
* `selectVoteStore`), keeping it out of any browser bundle — the browser uses IndexedDB. One
|
|
14
|
+
* row per contest, keyed by `topic`; `votes` is stored as JSON and re-validated through
|
|
15
|
+
* {@link VoteSchema} on read so a hand-edited or corrupt row cannot smuggle a malformed vote
|
|
16
|
+
* back into the signer.
|
|
17
|
+
*/
|
|
18
|
+
/** The persisted `votes` column shape, re-validated on read (no `any` from `JSON.parse`). */
|
|
19
|
+
const StoredVotesSchema = z.array(VoteSchema);
|
|
20
|
+
/** The SQLite file name kept inside the `dataPath` directory. */
|
|
21
|
+
const DB_FILENAME = "pubsub-votes.sqlite";
|
|
22
|
+
export class SqliteVoteStore {
|
|
23
|
+
#db;
|
|
24
|
+
#listStmt;
|
|
25
|
+
#getStmt;
|
|
26
|
+
#putStmt;
|
|
27
|
+
#deleteStmt;
|
|
28
|
+
/** Open (creating if absent) the vote-intents DB inside the `dataPath` directory. */
|
|
29
|
+
constructor(dataPath) {
|
|
30
|
+
mkdirSync(dataPath, { recursive: true });
|
|
31
|
+
this.#db = new Database(join(dataPath, DB_FILENAME));
|
|
32
|
+
this.#db.pragma("journal_mode = WAL");
|
|
33
|
+
this.#db.exec(`CREATE TABLE IF NOT EXISTS vote_intents (
|
|
34
|
+
topic TEXT PRIMARY KEY,
|
|
35
|
+
address TEXT NOT NULL,
|
|
36
|
+
votes TEXT NOT NULL,
|
|
37
|
+
last_bucket INTEGER NOT NULL
|
|
38
|
+
)`);
|
|
39
|
+
this.#listStmt = this.#db.prepare("SELECT topic, address, votes, last_bucket FROM vote_intents");
|
|
40
|
+
this.#getStmt = this.#db.prepare("SELECT topic, address, votes, last_bucket FROM vote_intents WHERE topic = ?");
|
|
41
|
+
this.#putStmt = this.#db.prepare("INSERT OR REPLACE INTO vote_intents (topic, address, votes, last_bucket) VALUES (?, ?, ?, ?)");
|
|
42
|
+
this.#deleteStmt = this.#db.prepare("DELETE FROM vote_intents WHERE topic = ?");
|
|
43
|
+
}
|
|
44
|
+
#rowToIntent(row) {
|
|
45
|
+
return {
|
|
46
|
+
topic: row.topic,
|
|
47
|
+
address: row.address,
|
|
48
|
+
votes: StoredVotesSchema.parse(JSON.parse(row.votes)),
|
|
49
|
+
lastBucket: row.last_bucket
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
async list() {
|
|
53
|
+
return this.#listStmt.all().map((row) => this.#rowToIntent(row));
|
|
54
|
+
}
|
|
55
|
+
async get(topic) {
|
|
56
|
+
const row = this.#getStmt.get(topic);
|
|
57
|
+
return row === undefined ? undefined : this.#rowToIntent(row);
|
|
58
|
+
}
|
|
59
|
+
async put(intent) {
|
|
60
|
+
this.#putStmt.run(intent.topic, intent.address, JSON.stringify(intent.votes), intent.lastBucket);
|
|
61
|
+
}
|
|
62
|
+
async delete(topic) {
|
|
63
|
+
this.#deleteStmt.run(topic);
|
|
64
|
+
}
|
|
65
|
+
async destroy() {
|
|
66
|
+
this.#db.close();
|
|
67
|
+
}
|
|
68
|
+
}
|