215 lines
9.3 KiB
TypeScript
215 lines
9.3 KiB
TypeScript
// Implementation-agnostic contract for `KVStore`. Every backend (browser and
|
|
// HTTP) is proven equivalent by running this same suite against it, so a new
|
|
// backend cannot silently diverge from the primitive's semantics.
|
|
import { describe, expect, test } from 'vitest';
|
|
import type { KVScope, KVStore } from '../src/index.js';
|
|
|
|
export function runKVStoreContract(name: string, makeStore: () => KVStore): void {
|
|
describe(`KVStore contract: ${name}`, () => {
|
|
test('round-trips a value set then get', async () => {
|
|
const kv = makeStore();
|
|
await kv.set('greeting', 'hello');
|
|
expect(await kv.get<string>('greeting')).toBe('hello');
|
|
});
|
|
|
|
test('returns null for a missing key', async () => {
|
|
const kv = makeStore();
|
|
expect(await kv.get('nope')).toBeNull();
|
|
});
|
|
|
|
test('round-trips structured JSON values', async () => {
|
|
const kv = makeStore();
|
|
const value = { a: 1, b: ['x', 'y'], c: { nested: true } };
|
|
await kv.set('obj', value);
|
|
expect(await kv.get('obj')).toEqual(value);
|
|
});
|
|
|
|
test('overwrites an existing key', async () => {
|
|
const kv = makeStore();
|
|
await kv.set('k', 'first');
|
|
await kv.set('k', 'second');
|
|
expect(await kv.get<string>('k')).toBe('second');
|
|
});
|
|
|
|
test('remove deletes a key', async () => {
|
|
const kv = makeStore();
|
|
await kv.set('k', 'v');
|
|
await kv.remove('k');
|
|
expect(await kv.get('k')).toBeNull();
|
|
});
|
|
|
|
test('defaults to the account scope', async () => {
|
|
const kv = makeStore();
|
|
await kv.set('k', 'v'); // no scope → account
|
|
expect(await kv.get('k', 'account')).toBe('v');
|
|
expect(await kv.get('k', 'device')).toBeNull();
|
|
});
|
|
|
|
test('isolates the device and account scopes', async () => {
|
|
const kv = makeStore();
|
|
await kv.set('k', 'device-val', 'device');
|
|
await kv.set('k', 'account-val', 'account');
|
|
expect(await kv.get('k', 'device')).toBe('device-val');
|
|
expect(await kv.get('k', 'account')).toBe('account-val');
|
|
});
|
|
|
|
test('keys() lists keys in a scope, filtered by prefix', async () => {
|
|
const kv = makeStore();
|
|
await kv.set('ui:width', 1, 'device');
|
|
await kv.set('ui:height', 2, 'device');
|
|
await kv.set('other', 3, 'device');
|
|
await kv.set('ui:acct', 4, 'account');
|
|
const uiKeys = await kv.keys('ui:', 'device');
|
|
expect([...uiKeys].sort()).toEqual(['ui:height', 'ui:width']);
|
|
});
|
|
|
|
test('keys() with no prefix lists every key in the scope', async () => {
|
|
const kv = makeStore();
|
|
await kv.set('a', 1);
|
|
await kv.set('b', 2);
|
|
expect([...(await kv.keys())].sort()).toEqual(['a', 'b']);
|
|
});
|
|
|
|
test('set(undefined) clears the key instead of corrupting it', async () => {
|
|
const kv = makeStore();
|
|
await kv.set('k', 'v');
|
|
await kv.set('k', undefined);
|
|
// Must return null, not throw — a stored literal "undefined" would throw
|
|
// on the JSON.parse in get().
|
|
expect(await kv.get('k')).toBeNull();
|
|
expect(await kv.keys()).not.toContain('k');
|
|
});
|
|
|
|
test('remove is scoped', async () => {
|
|
const kv = makeStore();
|
|
await kv.set('k', 'device-val', 'device');
|
|
await kv.set('k', 'account-val', 'account');
|
|
await kv.remove('k', 'device');
|
|
expect(await kv.get('k', 'device')).toBeNull();
|
|
expect(await kv.get('k', 'account')).toBe('account-val');
|
|
});
|
|
|
|
// Both backends decide "this write is a delete" by inspecting the value, not
|
|
// by trial-serializing it: `JSON.stringify` runs caller code, so a probe
|
|
// would reclassify `{ toJSON: () => undefined }` as a delete on one backend
|
|
// while the other rejects it. A value that cannot be stored is refused, and
|
|
// a refused write leaves the previous value alone.
|
|
test('a toJSON returning undefined is refused, not silently treated as a delete', async () => {
|
|
const kv = makeStore();
|
|
await kv.set('k', 'present');
|
|
|
|
await expect(kv.set('k', { toJSON: () => undefined })).rejects.toThrow();
|
|
expect(await kv.get('k')).toBe('present');
|
|
});
|
|
|
|
test('the scope is validated before anything reads the value', async () => {
|
|
const kv = makeStore();
|
|
let reads = 0;
|
|
const spy = {
|
|
get value() {
|
|
reads += 1;
|
|
return reads;
|
|
},
|
|
};
|
|
|
|
// The key is opaque and never validated, but the scope still is — and it is
|
|
// checked before the value is serialized, so an invalid scope cannot make
|
|
// caller code (a getter, a toJSON) run on the way to a rejection.
|
|
await expect(kv.set('k', spy, 'Device' as KVScope)).rejects.toThrow(/unknown KV scope/);
|
|
expect(reads).toBe(0);
|
|
});
|
|
|
|
// Scopes arrive as ordinary values (the zustand adapter passes one through
|
|
// verbatim), so a typo is a runtime possibility the type cannot prevent.
|
|
// Every backend must fail closed on one, and identically: a backend that
|
|
// guessed would either strand data in an invisible namespace or — worse for
|
|
// a server-backed one — send a value the caller believed was device-local.
|
|
test('an unknown scope fails closed rather than being guessed', async () => {
|
|
const kv = makeStore();
|
|
const unknownScope = 'Device' as KVScope;
|
|
|
|
await expect(kv.set('k', 'v', unknownScope)).rejects.toThrow(/unknown KV scope/);
|
|
await expect(kv.get('k', unknownScope)).rejects.toThrow(/unknown KV scope/);
|
|
await expect(kv.remove('k', unknownScope)).rejects.toThrow(/unknown KV scope/);
|
|
await expect(kv.keys('', unknownScope)).rejects.toThrow(/unknown KV scope/);
|
|
});
|
|
|
|
// Coverage matrix — prefix-charset. The prefix is a literal, byte-for-byte
|
|
// comparison, never a pattern. The characters that matter are the ones the
|
|
// obvious SQL translation `key LIKE prefix || '%'` would treat as wildcards
|
|
// or escapes: `%`, `_`, and — the one an implementer skips because they
|
|
// assume a key can never contain it — `\\`, PostgreSQL's default LIKE escape.
|
|
// Each must match only itself, and combinations must not interact.
|
|
test.each([
|
|
['%', 'literal-%', ['literal-%match', 'literal-Xmatch']],
|
|
['_', 'literal-_', ['literal-_match', 'literal-Xmatch']],
|
|
['a backslash', 'literal-\\', ['literal-\\match', 'literal-Xmatch']],
|
|
['%_ combined', 'p-%_', ['p-%_match', 'p-XYmatch']],
|
|
['backslash-percent combined', 'p-\\%', ['p-\\%match', 'p-\\Xmatch']],
|
|
])('keys() matches a %s prefix literally, not as a pattern', async (_name, prefix, keys) => {
|
|
const kv = makeStore();
|
|
const [shouldMatch, shouldNotMatch] = keys as [string, string];
|
|
await kv.set(shouldMatch, 1);
|
|
await kv.set(shouldNotMatch, 2);
|
|
|
|
expect(await kv.keys(prefix as string)).toEqual([shouldMatch]);
|
|
});
|
|
|
|
// Coverage matrix — key-validity. The key domain is truly opaque and
|
|
// unconstrained: every representative shape below is a legitimate key and
|
|
// must round-trip on every backend — set/get consistent, listed, removable —
|
|
// with no rejection and no mangling, whatever it looks like. (Whole-key `.` /
|
|
// `..` and unpaired surrogates are excluded here only because they are HTTP
|
|
// *transport* limits, not key-domain rejections; they are covered separately.)
|
|
test.each([
|
|
['an empty string', ''],
|
|
['a NUL code point', 'ns:before\u0000after'],
|
|
['a control character', 'ns:tab\u0009here'],
|
|
['a separator', 'editor-current-scene:stage/one'],
|
|
['a backslash', 'document-migration:stage\\one'],
|
|
['a traversal shape, harmless as opaque data', 'a/../../b'],
|
|
['a percent sign', 'k%2Fnot-decoded'],
|
|
['an underscore', 'ns_scene_1'],
|
|
['a colon and trailing spaces', 'ns: a b '],
|
|
['a BMP unicode character', 'ns:\u573A-\u20AC'],
|
|
['an astral emoji (paired surrogates)', 'ns:scene-\uD83D\uDD11'],
|
|
// No length ceiling: an arbitrarily long composed key is just as valid.
|
|
['a very long composed key', `editor-current-scene:${'s'.repeat(5000)}`],
|
|
['many special characters at once', 'ns:/\\%_: .\u573A\uD83D\uDD11'],
|
|
])('round-trips an opaque key with %s across every backend', async (_name, key) => {
|
|
const kv = makeStore();
|
|
await kv.set(key, { ok: true });
|
|
expect(await kv.get(key)).toEqual({ ok: true });
|
|
expect(await kv.keys()).toContain(key);
|
|
await kv.remove(key);
|
|
expect(await kv.get(key)).toBeNull();
|
|
expect(await kv.keys()).not.toContain(key);
|
|
});
|
|
|
|
// A prefix is not a path segment, so the dot-segment rule that governs keys
|
|
// does not reach it: `.` is the legitimate prefix of a `.hidden`-style key.
|
|
// Backends must agree, or a listing that works in the browser returns 400
|
|
// against a server.
|
|
test('keys() accepts a dot prefix, which is not a dot segment', async () => {
|
|
const kv = makeStore();
|
|
await kv.set('.hidden', 1);
|
|
await kv.set('..parent', 2);
|
|
await kv.set('visible', 3);
|
|
|
|
expect([...(await kv.keys('.'))].sort()).toEqual(['..parent', '.hidden']);
|
|
expect(await kv.keys('..')).toEqual(['..parent']);
|
|
});
|
|
|
|
test('keys() does not repeat a key', async () => {
|
|
const kv = makeStore();
|
|
await kv.set('one', 1);
|
|
await kv.set('one', 2);
|
|
await kv.set('two', 3);
|
|
|
|
const keys = await kv.keys();
|
|
expect([...new Set(keys)]).toHaveLength(keys.length);
|
|
expect([...keys].sort()).toEqual(['one', 'two']);
|
|
});
|
|
});
|
|
}
|