capsite/lib: MapStore and patterns work-alikes
Enough of @agoric/store's MapStore and @endo/patterns' M for the
credential-rotation design: makeMapStore(label, { keyShape, valueShape },
kv) keeps entries in a DO's sync KV under store:<label>:, and readOnly()
hands out the queries alone. Facets are workerd RpcTargets in place of
endo's Far.
Unlike endo, error messages never include keys or values: the values
here are credentials, and messages cross RPC into logs.
Generated-by: Claude Opus 5.5
This commit is contained in:
1 parent
1d8a220bbc
commit
9179bdbf38
10 files changed
+2586
No files matched your search
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* @file a work-alike for enough of @endo/patterns: `M.string()`, `M.any()`,
|
||||
* and literal patterns (a string, number, boolean, or null matches only
|
||||
* itself, so `"cookie"` as a key shape admits exactly one key).
|
||||
* @see https://github.com/endojs/endo/tree/master/packages/patterns
|
||||
*/
|
||||
|
||||
type MatcherKind = "string" | "any";
|
||||
|
||||
/** a matcher made by {@link M}; recognized by identity, so it is meaningful
|
||||
* only in the realm that made it (it does not survive an RPC copy) */
|
||||
export type Matcher = Readonly<{ toString(): string }>;
|
||||
export type Pattern = Matcher | string | number | boolean | null;
|
||||
|
||||
const kinds = new WeakMap<object, MatcherKind>();
|
||||
|
||||
const makeMatcher = (kind: MatcherKind, label: string): Matcher => {
|
||||
const matcher = Object.freeze({ toString: () => label });
|
||||
kinds.set(matcher, kind);
|
||||
return matcher;
|
||||
};
|
||||
|
||||
const STRING = makeMatcher("string", "M.string()");
|
||||
const ANY = makeMatcher("any", "M.any()");
|
||||
|
||||
export const M = Object.freeze({
|
||||
string: (): Matcher => STRING,
|
||||
any: (): Matcher => ANY,
|
||||
});
|
||||
|
||||
export function matches(specimen: unknown, pattern: Pattern): boolean {
|
||||
if (typeof pattern === "object" && pattern !== null) {
|
||||
const kind = kinds.get(pattern);
|
||||
if (kind === "string") return typeof specimen === "string";
|
||||
if (kind === "any") return true;
|
||||
throw new TypeError("not a pattern");
|
||||
}
|
||||
return Object.is(specimen, pattern);
|
||||
}
|
||||
|
||||
/**
|
||||
* throw unless `specimen` matches `pattern`. Unlike endo's, the message
|
||||
* names the pattern and `label` but never the specimen: specimens here
|
||||
* can be credentials, and messages cross RPC and land in logs.
|
||||
*/
|
||||
export function mustMatch(specimen: unknown, pattern: Pattern, label: string): void {
|
||||
if (!matches(specimen, pattern)) {
|
||||
const shape = typeof pattern === "object" && pattern !== null ? String(pattern) : JSON.stringify(pattern);
|
||||
throw new TypeError(`${label}: must match ${shape}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* @file a work-alike for enough of @agoric/store's MapStore: string keys,
|
||||
* shapes checked with ./patterns.js, entries kept in a Durable Object's
|
||||
* synchronous KV storage. The facets are RpcTargets (workerd's / capnweb's,
|
||||
* in place of endo's `Far`), so a store or its read-only view can be
|
||||
* handed out by reference.
|
||||
* @see https://github.com/Agoric/agoric-sdk/tree/master/packages/store
|
||||
*/
|
||||
|
||||
import { RpcTarget } from "cloudflare:workers";
|
||||
import { M, mustMatch, type Pattern } from "./patterns.js";
|
||||
|
||||
/** the storage a store needs: a DO's `ctx.storage.kv`, or a stand-in */
|
||||
export type Kv = Pick<SyncKvStorage, "get" | "put" | "delete" | "list">;
|
||||
|
||||
export type StoreShapes = { keyShape?: Pattern; valueShape?: Pattern };
|
||||
|
||||
/**
|
||||
* Entries live under `store:<label>:<key>`, so the label also names the
|
||||
* storage area (as a baggage key does for endo's durable stores): two
|
||||
* stores with one label on one Kv are the same store.
|
||||
*
|
||||
* Error messages name the label and shapes, never keys or values.
|
||||
*/
|
||||
export function makeMapStore<V>(label: string, { keyShape = M.string(), valueShape = M.any() }: StoreShapes, kv: Kv) {
|
||||
if (label.includes(":")) throw new TypeError("store label must not contain ':'");
|
||||
const prefix = `store:${label}:`;
|
||||
const core: Core<V> = {
|
||||
label,
|
||||
has: (key) => kv.get(prefix + key) !== undefined,
|
||||
get: (key) => kv.get<V>(prefix + key),
|
||||
put: (key, value) => kv.put(prefix + key, value),
|
||||
delete: (key) => {
|
||||
kv.delete(prefix + key);
|
||||
},
|
||||
entries: () => [...kv.list<V>({ prefix })].map(([k, v]): [string, V] => [k.slice(prefix.length), v]),
|
||||
checkKey: (key) => mustMatch(key, keyShape, `${label}: key`),
|
||||
checkValue: (value) => mustMatch(value, valueShape, `${label}: value`),
|
||||
};
|
||||
return new MapStore<V>(core);
|
||||
}
|
||||
|
||||
type Core<V> = {
|
||||
label: string;
|
||||
has(key: string): boolean;
|
||||
get(key: string): V | undefined;
|
||||
put(key: string, value: V): void;
|
||||
delete(key: string): void;
|
||||
entries(): [string, V][];
|
||||
checkKey(key: unknown): void;
|
||||
checkValue(value: unknown): void;
|
||||
};
|
||||
|
||||
/** the queries */
|
||||
function reads<V>(core: Core<V>) {
|
||||
return {
|
||||
has: (key: string): boolean => core.has(key),
|
||||
get: (key: string): V => {
|
||||
const value = core.get(key);
|
||||
if (value === undefined) throw new RangeError(`${core.label}: key not found`);
|
||||
return value;
|
||||
},
|
||||
keys: (): string[] => core.entries().map(([k]) => k),
|
||||
values: (): V[] => core.entries().map(([, v]) => v),
|
||||
entries: (): [string, V][] => core.entries(),
|
||||
getSize: (): number => core.entries().length,
|
||||
};
|
||||
}
|
||||
|
||||
/** a store's queries only: what a reader of a rotating credential holds */
|
||||
export class ReadonlyMapStore<V> extends RpcTarget {
|
||||
#reads: ReturnType<typeof reads<V>>;
|
||||
|
||||
constructor(core: Core<V>) {
|
||||
super();
|
||||
this.#reads = reads(core);
|
||||
}
|
||||
|
||||
has(key: string): boolean {
|
||||
return this.#reads.has(key);
|
||||
}
|
||||
get(key: string): V {
|
||||
return this.#reads.get(key);
|
||||
}
|
||||
keys(): string[] {
|
||||
return this.#reads.keys();
|
||||
}
|
||||
values(): V[] {
|
||||
return this.#reads.values();
|
||||
}
|
||||
entries(): [string, V][] {
|
||||
return this.#reads.entries();
|
||||
}
|
||||
getSize(): number {
|
||||
return this.#reads.getSize();
|
||||
}
|
||||
}
|
||||
|
||||
/** a store's full authority: the queries plus `init`/`set`/`delete`, and
|
||||
* {@link MapStore.readOnly} to hand out the queries alone */
|
||||
export class MapStore<V> extends ReadonlyMapStore<V> {
|
||||
#core: Core<V>;
|
||||
|
||||
constructor(core: Core<V>) {
|
||||
super(core);
|
||||
this.#core = core;
|
||||
}
|
||||
|
||||
/** add a key that is not yet present */
|
||||
init(key: string, value: V): void {
|
||||
this.#core.checkKey(key);
|
||||
this.#core.checkValue(value);
|
||||
if (this.#core.has(key)) throw new RangeError(`${this.#core.label}: key already registered`);
|
||||
this.#core.put(key, value);
|
||||
}
|
||||
|
||||
/** replace the value of a key that is present */
|
||||
set(key: string, value: V): void {
|
||||
this.#core.checkKey(key);
|
||||
this.#core.checkValue(value);
|
||||
if (!this.#core.has(key)) throw new RangeError(`${this.#core.label}: key not found`);
|
||||
this.#core.put(key, value);
|
||||
}
|
||||
|
||||
delete(key: string): void {
|
||||
if (!this.#core.has(key)) throw new RangeError(`${this.#core.label}: key not found`);
|
||||
this.#core.delete(key);
|
||||
}
|
||||
|
||||
readOnly(): ReadonlyMapStore<V> {
|
||||
return new ReadonlyMapStore(this.#core);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user