SOURCE / PINNED RELEASE
Made of little things.
Powder Tool V600Billion
- Release
- 142767edcab8…
- Author-recorded commit
- 6d92971effd0…
- License
- LICENSE
- Author’s source reference
- nostr://npub1fllw8kw0thjj55wds0uugcnp5kej2nfxd36eruq39d56wwz8r44q5q78wj/wss%3A%2F%2Fgit.napplet.soy%2F/powder-toy
Archive hash verified: ed7d6a8ea7083197…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
/**
* /powder on napplet storage, against an in-memory stand-in for Emscripten's
* FS (only the calls storage-fs.js makes). What must hold: a round trip keeps
* bytes and mtimes; an unchanged tree writes nothing; a deleted file leaves
* storage; a populate that failed never deletes anything; oversized and
* unreadable entries are skipped, not fatal; large files go into pieces a
* strict host (napplet.soy) takes, and a file such a host refuses does not
* stop the others from being kept.
*/
import assert from 'node:assert/strict';
import test from 'node:test';
import { CHUNK, CHUNK_PREFIX, createNappletFS, keyFor } from '../src/storage-fs.js';
const DIR = 0o040000, FILE = 0o100000;
function fakeFS() {
let clock = 1_000_000;
const nodes = new Map([['/', { dir: true, mtime: clock }]]);
const parentOf = (path) => path.slice(0, path.lastIndexOf('/')) || '/';
const touch = (path) => { const node = nodes.get(path); if (node) node.mtime = ++clock; };
const need = (path) => { const node = nodes.get(path); if (!node) throw Object.assign(new Error(`ENOENT ${path}`), { code: 'ENOENT' }); return node; };
return {
nodes,
tick: () => ++clock,
readdir(path) {
need(path);
const prefix = path === '/' ? '/' : `${path}/`;
return ['.', '..', ...[...nodes.keys()].filter((key) => key !== path && key.startsWith(prefix) && !key.slice(prefix.length).includes('/')).map((key) => key.slice(prefix.length))];
},
stat(path) {
const node = need(path);
return { mode: node.dir ? DIR | 0o777 : FILE | 0o666, mtime: new Date(node.mtime), size: node.dir ? 4096 : node.data.length };
},
isDir: (mode) => (mode & 0o170000) === DIR,
isFile: (mode) => (mode & 0o170000) === FILE,
mkdirTree(path) {
let current = '';
for (const part of path.split('/').filter(Boolean)) {
current += `/${part}`;
if (!nodes.has(current)) { nodes.set(current, { dir: true, mtime: ++clock }); touch(parentOf(current)); }
}
},
writeFile(path, data) {
need(parentOf(path));
nodes.set(path, { dir: false, data: Uint8Array.from(typeof data === 'string' ? Buffer.from(data) : data), mtime: ++clock });
touch(parentOf(path));
},
readFile(path) { return need(path).data; },
chmod() {},
utime(path, atime, mtime) { need(path).mtime = Math.max(atime, mtime); },
unlink(path) { need(path); nodes.delete(path); touch(parentOf(path)); },
};
}
function fakeStore({ failKeys = false } = {}) {
const map = new Map();
const calls = { set: 0, remove: 0 };
return {
map, calls,
async get(key) { return map.has(key) ? map.get(key) : null; },
async set(key, value) { calls.set++; map.set(key, value); },
async remove(key) { calls.remove++; map.delete(key); },
async keys() { if (failKeys) throw new Error('storage did not answer'); return [...map.keys()]; },
};
}
/**
* Storage with napplet.soy's limits (packages/runtime/src/storage.ts and host.ts
* there): 256 keys, 1 MiB of keys and values in all, and a message over
* 360 000 characters is dropped, which the napplet sees as a timeout.
*/
function strictStore({ quota = 1024 * 1024, maxKeys = 256 } = {}) {
const store = fakeStore();
const used = (skip) => [...store.map].filter(([key]) => key !== skip).reduce((n, [key, value]) => n + key.length + value.length, 0);
const set = store.set;
store.set = async (key, value) => {
if (JSON.stringify({ type: 'storage.set', id: '00000000-0000-0000-0000-000000000000', key, value }).length > 360000) throw new Error('State request timed out');
if ((!store.map.has(key) && store.map.size >= maxKeys) || used(key) + key.length + value.length > quota) throw new Error('Storage quota exceeded');
return set(key, value);
};
return store;
}
const mountAt = '/powder';
const sync = (fsType, populate) => new Promise((resolve) => fsType.syncfs({ mountpoint: mountAt }, populate, resolve));
function setup(store, fs = fakeFS(), log = () => {}) {
fs.mkdirTree(mountAt);
return { fs, fsType: createNappletFS({ FS: fs, MEMFS: { mount: () => null }, store, log }) };
}
test('a round trip keeps directories, bytes and mtimes, and an unchanged tree writes nothing', async () => {
const store = fakeStore();
const first = setup(store);
first.fs.mkdirTree('/powder/Saves');
first.fs.writeFile('/powder/Saves/tower.cps', new Uint8Array([0x4f, 0x50, 0x53, 0x31, 0, 255]));
first.fs.writeFile('/powder/powder.pref', '{"Scale":1}');
assert.equal(await sync(first.fsType, true), null);
assert.equal(await sync(first.fsType, false), null);
assert.deepEqual([...store.map.keys()].sort(), ['fs:/powder/Saves', 'fs:/powder/Saves/tower.cps', 'fs:/powder/powder.pref']);
const written = store.calls.set;
assert.equal(await sync(first.fsType, false), null);
assert.equal(store.calls.set, written, 'a second persist without changes wrote again');
// A new session: empty memory, same storage.
const second = setup(store);
await sync(second.fsType, true);
assert.deepEqual([...second.fs.readFile('/powder/Saves/tower.cps')], [0x4f, 0x50, 0x53, 0x31, 0, 255]);
assert.equal(Buffer.from(second.fs.readFile('/powder/powder.pref')).toString(), '{"Scale":1}');
assert.equal(second.fs.stat('/powder/Saves/tower.cps').mtime.getTime(), first.fs.stat('/powder/Saves/tower.cps').mtime.getTime());
const before = store.calls.set;
await sync(second.fsType, false);
assert.equal(store.calls.set, before, 'restoring must not look like a change');
});
test('a changed file is written again and a deleted one leaves storage', async () => {
const store = fakeStore();
const { fs, fsType } = setup(store);
await sync(fsType, true);
fs.writeFile('/powder/a.txt', 'one');
fs.writeFile('/powder/b.txt', 'two');
await sync(fsType, false);
fs.writeFile('/powder/a.txt', 'three');
fs.unlink('/powder/b.txt');
await sync(fsType, false);
assert.equal(Buffer.from(JSON.parse(store.map.get('fs:/powder/a.txt')).data, 'base64').toString(), 'three');
assert.equal(store.map.has('fs:/powder/b.txt'), false);
});
test('when populate fails the game still starts, and nothing is ever deleted', async () => {
const store = fakeStore();
store.map.set('fs:/powder/Saves/precious.cps', JSON.stringify({ path: '/powder/Saves/precious.cps', mode: FILE | 0o666, mtime: 5, data: 'AAEC' }));
const failing = { ...store, keys: async () => { throw new Error('storage did not answer'); } };
const logs = [];
const { fs, fsType } = setup(failing, fakeFS(), (line) => logs.push(line));
assert.equal(await sync(fsType, true), null, 'populate must report success so main() runs');
fs.writeFile('/powder/new.txt', 'x');
await sync(fsType, false);
assert.ok(store.map.has('fs:/powder/Saves/precious.cps'), 'a save vanished after a failed load');
assert.ok(store.map.has('fs:/powder/new.txt'), 'new files are still kept');
assert.equal(store.calls.remove, 0);
assert.ok(logs.some((line) => line.includes('storage did not answer')));
});
test('unreadable entries are skipped and entries outside /powder are ignored', async () => {
const store = fakeStore();
store.map.set('fs:/powder/broken', '{not json');
store.map.set('fs:/elsewhere/file', JSON.stringify({ path: '/elsewhere/file', mtime: 1, data: '' }));
store.map.set('fs:/powder/ok.txt', JSON.stringify({ path: '/powder/ok.txt', mode: FILE | 0o666, mtime: 7, data: Buffer.from('fine').toString('base64') }));
store.map.set('favourites', '[]');
const { fs, fsType } = setup(store);
await sync(fsType, true);
assert.equal(Buffer.from(fs.readFile('/powder/ok.txt')).toString(), 'fine');
assert.equal(fs.nodes.has('/elsewhere/file'), false);
});
test('long paths get a hashed key and still round-trip', async () => {
const store = fakeStore();
const { fs, fsType } = setup(store);
await sync(fsType, true);
const name = `/powder/Saves/${'very long save name '.repeat(12).trim()}.cps`;
fs.mkdirTree('/powder/Saves');
fs.writeFile(name, 'long');
await sync(fsType, false);
const key = keyFor(name);
assert.ok(key.length <= 200 && key.startsWith('fs:#'), key);
assert.equal(JSON.parse(store.map.get(key)).path, name);
const again = setup(store);
await sync(again.fsType, true);
assert.equal(Buffer.from(again.fs.readFile(name)).toString(), 'long');
});
test('a file too large to keep at all stays in memory and is reported', async () => {
const store = fakeStore();
const logs = [];
const { fs, fsType } = setup(store, fakeFS(), (line) => logs.push(line));
await sync(fsType, true);
fs.writeFile('/powder/huge.bin', new Uint8Array(6.5 * 1024 * 1024));
await sync(fsType, false);
assert.equal(store.map.has('fs:/powder/huge.bin'), false);
assert.ok(logs.some((line) => line.includes('too large')));
});
const bytes = (length, seed = 1) => Uint8Array.from({ length }, (_, i) => (i * 31 + seed * 7) & 255);
test('a large file goes into pieces a strict host takes, and comes back whole', async () => {
const store = strictStore({ quota: 4 * 1024 * 1024 });
const { fs, fsType } = setup(store);
await sync(fsType, true);
fs.mkdirTree('/powder/Saves');
const data = bytes(500_000);
fs.writeFile('/powder/Saves/big.cps', data);
assert.equal(await sync(fsType, false), null);
const head = JSON.parse(store.map.get('fs:/powder/Saves/big.cps'));
assert.equal(head.chunks.length, 2, 'base64 of 500 kB is three pieces: the entry and two more');
assert.equal(head.data.length, CHUNK);
for (const value of store.map.values()) assert.ok(value.length <= CHUNK + 200, 'no value is larger than a piece');
const again = setup(store);
await sync(again.fsType, true);
assert.deepEqual(again.fs.readFile('/powder/Saves/big.cps'), data);
});
test('rewriting a large file replaces its pieces, and a write that fails keeps the old version', async () => {
const store = fakeStore();
const { fs, fsType } = setup(store);
await sync(fsType, true);
fs.writeFile('/powder/big.bin', bytes(400_000, 1));
await sync(fsType, false);
const first = JSON.parse(store.map.get('fs:/powder/big.bin')).chunks;
fs.writeFile('/powder/big.bin', bytes(400_000, 2));
await sync(fsType, false);
const second = JSON.parse(store.map.get('fs:/powder/big.bin')).chunks;
assert.notDeepEqual(second, first);
assert.deepEqual([...store.map.keys()].filter((key) => key.startsWith(CHUNK_PREFIX)).sort(), [...second].sort(), 'the old pieces are gone');
// The host stops taking writes half way through the third version.
let writes = 0;
const set = store.set;
store.set = async (key, value) => { if (++writes > 1) throw new Error('Storage quota exceeded'); return set(key, value); };
fs.writeFile('/powder/big.bin', bytes(400_000, 3));
await sync(fsType, false);
store.set = set;
const again = setup(store);
await sync(again.fsType, true);
assert.deepEqual(again.fs.readFile('/powder/big.bin'), bytes(400_000, 2), 'the second version is still whole');
assert.deepEqual([...store.map.keys()].filter((key) => key.startsWith(CHUNK_PREFIX)).sort(), [...second].sort(), 'no pieces left over');
});
test('a file a full host refuses does not stop the others, is reported once, and waits until it changes', async () => {
const store = strictStore({ quota: 300_000 });
const trouble = [];
const { fs, fsType } = setup(store);
const fsType2 = createNappletFS({ FS: fs, MEMFS: { mount: () => null }, store, log: () => {}, onTrouble: (path, reason) => trouble.push([path, reason]) });
await sync(fsType2, true);
fs.mkdirTree('/powder/Saves');
fs.writeFile('/powder/Saves/huge.cps', bytes(250_000));
fs.writeFile('/powder/powder.pref', '{"Scale":2}');
assert.equal(await sync(fsType2, false), null);
assert.ok(store.map.has('fs:/powder/powder.pref'), 'the settings are kept');
assert.equal(store.map.has('fs:/powder/Saves/huge.cps'), false);
assert.deepEqual(trouble.map(([path]) => path), ['/powder/Saves/huge.cps']);
assert.match(trouble[0][1], /quota/);
const attempts = store.calls.set;
fs.writeFile('/powder/powder.pref', '{"Scale":1}');
await sync(fsType2, false);
assert.equal(store.calls.set, attempts + 1, 'only the changed settings were written, the refused save was not tried again');
assert.equal(trouble.length, 1, 'reported once');
fs.writeFile('/powder/Saves/huge.cps', bytes(1000));
await sync(fsType2, false);
assert.ok(store.map.has('fs:/powder/Saves/huge.cps'), 'kept once it fits');
assert.equal(fsType2.state.refused.size, 0);
});
test('pieces no entry names are removed at the next start, but only after a complete load', async () => {
const store = fakeStore();
store.map.set(`${CHUNK_PREFIX}old:1:/powder/gone.bin`, 'AAAA');
store.map.set('fs:/powder/ok.txt', JSON.stringify({ path: '/powder/ok.txt', mode: FILE | 0o666, mtime: 7, data: 'b2s=' }));
const failing = setup({ ...store, keys: async () => { throw new Error('storage did not answer'); } });
await sync(failing.fsType, true);
assert.ok(store.map.has(`${CHUNK_PREFIX}old:1:/powder/gone.bin`), 'kept while the load failed');
const { fsType } = setup(store);
await sync(fsType, true);
assert.equal(store.map.has(`${CHUNK_PREFIX}old:1:/powder/gone.bin`), false);
assert.ok(store.map.has('fs:/powder/ok.txt'));
});
