Matchers are now exactly what @endo/patterns makes: tagged records as from pass-style's makeTagged (M.string() is match:string with payload [], M.any() is match:any with payload undefined). Structured clone drops their symbol-keyed and non-enumerable properties, so they cross DO storage and Workers RPC in marshal's smallcaps encoding (the subset patterns need). MapStoreDo is a MapStore as a DO of its own: its creator fixes label and (encoded) shapes once; its id is full authority, and readOnly() hands out the queries alone. Generated-by: Claude Opus 5.5
130 lines
4.9 KiB
TypeScript
130 lines
4.9 KiB
TypeScript
/**
|
|
* @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). Matchers
|
|
* are the same data endo makes: tagged records, as from @endo/pass-style's
|
|
* `makeTagged` (frozen here, where endo would harden).
|
|
*
|
|
* Structured clone (DO storage, Workers RPC) drops a tagged record's
|
|
* symbol-keyed and non-enumerable properties, so patterns cross those
|
|
* boundaries in endo's smallcaps encoding: see {@link toSmallcaps}.
|
|
* @see https://github.com/endojs/endo/tree/master/packages/patterns
|
|
* @see https://github.com/endojs/endo/blob/master/packages/marshal/src/encodeToSmallcaps.js
|
|
*/
|
|
|
|
const PASS_STYLE = Symbol.for("passStyle");
|
|
|
|
export type Tagged = Readonly<{ [Symbol.toStringTag]: string; payload: unknown }>;
|
|
export type Pattern = Tagged | string | number | boolean | null;
|
|
|
|
/** @endo/pass-style's `makeTagged` */
|
|
export function makeTagged(tag: string, payload: unknown): Tagged {
|
|
return Object.freeze(
|
|
Object.create(Object.prototype, {
|
|
[PASS_STYLE]: { value: "tagged" },
|
|
[Symbol.toStringTag]: { value: tag },
|
|
payload: { value: payload, enumerable: true },
|
|
}),
|
|
) as Tagged;
|
|
}
|
|
|
|
const isTagged = (x: unknown): x is Tagged =>
|
|
typeof x === "object" && x !== null && (x as Record<symbol, unknown>)[PASS_STYLE] === "tagged";
|
|
|
|
const StringShape = makeTagged("match:string", Object.freeze([]));
|
|
const AnyShape = makeTagged("match:any", undefined);
|
|
|
|
export const M = Object.freeze({
|
|
string: (): Tagged => StringShape,
|
|
any: (): Tagged => AnyShape,
|
|
});
|
|
|
|
export function matches(specimen: unknown, pattern: Pattern): boolean {
|
|
if (typeof pattern === "object" && pattern !== null) {
|
|
const tag = isTagged(pattern) ? pattern[Symbol.toStringTag] : undefined;
|
|
switch (tag) {
|
|
case "match:string":
|
|
return typeof specimen === "string";
|
|
case "match:any":
|
|
return true;
|
|
default:
|
|
throw new TypeError("not a pattern");
|
|
}
|
|
}
|
|
return Object.is(specimen, pattern);
|
|
}
|
|
|
|
/** the pattern as written in code, e.g. `M.string()` or `"cookie"` */
|
|
export function describe(pattern: Pattern): string {
|
|
if (typeof pattern === "object" && pattern !== null) {
|
|
return `M.${pattern[Symbol.toStringTag].replace(/^match:/, "")}()`;
|
|
}
|
|
return JSON.stringify(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)) {
|
|
throw new TypeError(`${label}: must match ${describe(pattern)}`);
|
|
}
|
|
}
|
|
|
|
/** a smallcaps-encoded passable, as JSON-able data */
|
|
export type Smallcaps = null | boolean | number | string | Smallcaps[] | { "#tag": string; payload: Smallcaps };
|
|
|
|
/** smallcaps quotes strings that start with one of these with `!` */
|
|
const startsSpecial = (s: string) => /^[!-\-]/.test(s);
|
|
|
|
/**
|
|
* the subset of @endo/marshal's smallcaps encoding that patterns here use:
|
|
* primitives, arrays, and tagged records (not a whole CapData: no `#`
|
|
* body prefix, no slots)
|
|
*/
|
|
export function toSmallcaps(passable: unknown): Smallcaps {
|
|
if (passable === undefined) return "#undefined";
|
|
if (passable === null || typeof passable === "boolean") return passable;
|
|
if (typeof passable === "number") {
|
|
if (Number.isNaN(passable)) return "#NaN";
|
|
if (passable === Infinity) return "#Infinity";
|
|
if (passable === -Infinity) return "#-Infinity";
|
|
return Object.is(passable, -0) ? 0 : passable;
|
|
}
|
|
if (typeof passable === "string") return startsSpecial(passable) ? `!${passable}` : passable;
|
|
if (Array.isArray(passable)) return passable.map(toSmallcaps);
|
|
if (isTagged(passable)) {
|
|
return { "#tag": toSmallcaps(passable[Symbol.toStringTag]) as string, payload: toSmallcaps(passable.payload) };
|
|
}
|
|
throw new TypeError("toSmallcaps: unsupported passable");
|
|
}
|
|
|
|
/** the inverse of {@link toSmallcaps} */
|
|
export function fromSmallcaps(encoding: Smallcaps): unknown {
|
|
if (encoding === null || typeof encoding === "boolean" || typeof encoding === "number") return encoding;
|
|
if (typeof encoding === "string") {
|
|
if (encoding.startsWith("!")) return encoding.slice(1);
|
|
switch (encoding) {
|
|
case "#undefined":
|
|
return undefined;
|
|
case "#NaN":
|
|
return NaN;
|
|
case "#Infinity":
|
|
return Infinity;
|
|
case "#-Infinity":
|
|
return -Infinity;
|
|
}
|
|
if (startsSpecial(encoding)) throw new TypeError("fromSmallcaps: unsupported special string");
|
|
return encoding;
|
|
}
|
|
if (Array.isArray(encoding)) return Object.freeze(encoding.map(fromSmallcaps));
|
|
if (typeof encoding === "object" && "#tag" in encoding) {
|
|
const { "#tag": tag, payload, ...rest } = encoding;
|
|
if (Object.keys(rest).length > 0) throw new TypeError("fromSmallcaps: extra properties on a tagged record");
|
|
return makeTagged(fromSmallcaps(tag) as string, fromSmallcaps(payload));
|
|
}
|
|
throw new TypeError("fromSmallcaps: unsupported encoding");
|
|
}
|