Skip to content

Commit f59d51b

Browse files
committed
feat(kernel-utils): treat {} as empty sheaf metadata
- evaluateMetadata returns a plain object; missing spec and nullish raw → {} - reject primitives, arrays, and non-plain objects; hint { value: myValue } - require EvaluatedSection.metadata; MetaData extends Record<string, unknown> - simplify metadataKey and decomposeMetadata; drop ifDefined in dispatch Made-with: Cursor
1 parent 9e6eb5e commit f59d51b

5 files changed

Lines changed: 206 additions & 87 deletions

File tree

packages/kernel-utils/src/sheaf/metadata.test.ts

Lines changed: 69 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ import {
1010

1111
describe('constant', () => {
1212
it('returns a constant spec with the given value', () => {
13-
expect(constant(42)).toStrictEqual({ kind: 'constant', value: 42 });
13+
expect(constant({ n: 42 })).toStrictEqual({
14+
kind: 'constant',
15+
value: { n: 42 },
16+
});
1417
});
1518

1619
it('evaluateMetadata returns the value regardless of args', () => {
@@ -22,59 +25,109 @@ describe('constant', () => {
2225

2326
describe('callable', () => {
2427
it('returns a callable spec wrapping the function', () => {
25-
const fn = (args: unknown[]) => args[0] as number;
28+
const fn = (args: unknown[]) => ({ out: args[0] as number });
2629
const spec = callable(fn);
2730
expect(spec).toStrictEqual({ kind: 'callable', fn });
2831
});
2932

3033
it('evaluateMetadata calls fn with args', () => {
31-
const fn = vi.fn((args: unknown[]) => (args[0] as number) * 2);
34+
const fn = vi.fn((args: unknown[]) => ({
35+
value: (args[0] as number) * 2,
36+
}));
3237
const spec = resolveMetaDataSpec(callable(fn));
33-
expect(evaluateMetadata(spec, [5])).toBe(10);
38+
expect(evaluateMetadata(spec, [5])).toStrictEqual({ value: 10 });
3439
expect(fn).toHaveBeenCalledWith([5]);
3540
});
3641
});
3742

3843
describe('source', () => {
3944
it('returns a source spec with the src string', () => {
40-
expect(source('(args) => args[0]')).toStrictEqual({
45+
expect(source('(args) => ({ x: args[0] })')).toStrictEqual({
4146
kind: 'source',
42-
src: '(args) => args[0]',
47+
src: '(args) => ({ x: args[0] })',
4348
});
4449
});
4550

4651
it('resolveMetaDataSpec compiles source to callable via compartment', () => {
47-
const mockFn = (args: unknown[]) => args[0] as number;
52+
const mockFn = (args: unknown[]) => ({ value: args[0] as number });
4853
const compartment = { evaluate: vi.fn(() => mockFn) };
49-
const spec = resolveMetaDataSpec(source('(args) => args[0]'), compartment);
54+
const spec = resolveMetaDataSpec(
55+
source<{ value: number }>('(args) => ({ value: args[0] })'),
56+
compartment,
57+
);
5058
expect(spec.kind).toBe('callable');
51-
expect(compartment.evaluate).toHaveBeenCalledWith('(args) => args[0]');
52-
expect(evaluateMetadata(spec, [99])).toBe(99);
59+
expect(compartment.evaluate).toHaveBeenCalledWith(
60+
'(args) => ({ value: args[0] })',
61+
);
62+
expect(evaluateMetadata(spec, [99])).toStrictEqual({ value: 99 });
5363
});
5464
});
5565

5666
describe('resolveMetaDataSpec', () => {
5767
it('passes constant spec through unchanged', () => {
58-
const spec = constant(42);
68+
const spec = constant({ answer: 42 });
5969
expect(resolveMetaDataSpec(spec)).toStrictEqual(spec);
6070
});
6171

6272
it('passes callable spec through unchanged', () => {
63-
const fn = (_args: unknown[]) => 0;
73+
const fn = (_args: unknown[]) => ({ count: 0 });
6474
const spec = callable(fn);
6575
expect(resolveMetaDataSpec(spec)).toStrictEqual(spec);
6676
});
6777

6878
it("throws if kind is 'source' and no compartment supplied", () => {
69-
expect(() => resolveMetaDataSpec(source('() => 0'))).toThrow(
79+
expect(() => resolveMetaDataSpec(source('() => ({})'))).toThrow(
7080
"compartment required to evaluate 'source' metadata",
7181
);
7282
});
7383
});
7484

7585
describe('evaluateMetadata', () => {
76-
it('returns undefined when spec is undefined', () => {
77-
expect(evaluateMetadata(undefined, [])).toBeUndefined();
78-
expect(evaluateMetadata(undefined, [1, 2])).toBeUndefined();
86+
it('returns empty object when spec is undefined', () => {
87+
expect(evaluateMetadata(undefined, [])).toStrictEqual({});
88+
expect(evaluateMetadata(undefined, [1, 2])).toStrictEqual({});
89+
});
90+
91+
it('normalizes null from callable to empty object', () => {
92+
const spec = resolveMetaDataSpec(
93+
callable(
94+
((_args: unknown[]) => null) as unknown as (
95+
args: unknown[],
96+
) => Record<string, unknown>,
97+
),
98+
);
99+
expect(evaluateMetadata(spec, [])).toStrictEqual({});
100+
});
101+
102+
it('throws when callable returns a primitive', () => {
103+
const spec = resolveMetaDataSpec(
104+
callable(
105+
((_args: unknown[]) => 7) as unknown as (
106+
args: unknown[],
107+
) => Record<string, unknown>,
108+
),
109+
);
110+
expect(() => evaluateMetadata(spec, [])).toThrow(/cannot be a primitive/u);
111+
expect(() => evaluateMetadata(spec, [])).toThrow(/value: myValue/u);
112+
});
113+
114+
it('throws when callable returns an array', () => {
115+
const spec = resolveMetaDataSpec(
116+
callable(((_args: unknown[]) => [1, 2]) as unknown as (
117+
args: unknown[],
118+
) => Record<string, unknown>),
119+
);
120+
expect(() => evaluateMetadata(spec, [])).toThrow(/cannot be an array/u);
121+
});
122+
123+
it('throws when callable returns a Date', () => {
124+
const spec = resolveMetaDataSpec(
125+
callable(
126+
((_args: unknown[]) => new Date()) as unknown as (
127+
args: unknown[],
128+
) => Record<string, unknown>,
129+
),
130+
);
131+
expect(() => evaluateMetadata(spec, [])).toThrow(/must be a plain object/u);
79132
});
80133
});

packages/kernel-utils/src/sheaf/metadata.ts

Lines changed: 61 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,36 +5,77 @@
55
import type { MetaDataSpec } from './types.ts';
66

77
/** Resolved spec: 'source' has been compiled away; only constant or callable remain. */
8-
export type ResolvedMetaDataSpec<M> =
8+
export type ResolvedMetaDataSpec<M extends Record<string, unknown>> =
99
| { kind: 'constant'; value: M }
1010
| { kind: 'callable'; fn: (args: unknown[]) => M };
1111

12+
const metadataPlainObjectHint =
13+
'Sheaf metadata must be a plain object; use e.g. { value: myValue } if you need to attach a primitive.';
14+
15+
const isPlainObjectRecord = (value: object): boolean => {
16+
const proto = Object.getPrototypeOf(value);
17+
return proto === null || proto === Object.prototype;
18+
};
19+
20+
/**
21+
* Normalize evaluated metadata: empty sentinel is `{}`; invalid shapes throw.
22+
*
23+
* @param raw - Result from constant value or callable, before validation.
24+
* @returns A plain object suitable for stalk metadata.
25+
*/
26+
const normalizeEvaluatedSheafMetadata = (
27+
raw: unknown,
28+
): Record<string, unknown> => {
29+
if (raw === undefined || raw === null) {
30+
return {};
31+
}
32+
if (typeof raw !== 'object') {
33+
throw new Error(
34+
`sheafify: metadata cannot be a primitive (${typeof raw}). ${metadataPlainObjectHint}`,
35+
);
36+
}
37+
if (Array.isArray(raw)) {
38+
throw new Error(
39+
`sheafify: metadata cannot be an array. ${metadataPlainObjectHint}`,
40+
);
41+
}
42+
if (!isPlainObjectRecord(raw)) {
43+
throw new Error(
44+
`sheafify: metadata must be a plain object. ${metadataPlainObjectHint}`,
45+
);
46+
}
47+
return raw as Record<string, unknown>;
48+
};
49+
1250
/**
1351
* Wrap a static value as a constant metadata spec.
1452
*
1553
* @param value - The static metadata value.
1654
* @returns A constant MetaDataSpec wrapping the value.
1755
*/
18-
export const constant = <M>(value: M): MetaDataSpec<M> =>
19-
harden({ kind: 'constant', value });
56+
export const constant = <M extends Record<string, unknown>>(
57+
value: M,
58+
): MetaDataSpec<M> => harden({ kind: 'constant', value });
2059

2160
/**
2261
* Wrap JS function source. Evaluated in a Compartment at sheafify construction time.
2362
*
2463
* @param src - JS source string of the form `(args) => M`.
2564
* @returns A source MetaDataSpec wrapping the source string.
2665
*/
27-
export const source = <M>(src: string): MetaDataSpec<M> =>
28-
harden({ kind: 'source', src });
66+
export const source = <M extends Record<string, unknown>>(
67+
src: string,
68+
): MetaDataSpec<M> => harden({ kind: 'source', src });
2969

3070
/**
3171
* Wrap a live function as a callable metadata spec.
3272
*
3373
* @param fn - Function from invocation args to metadata value.
34-
* @returns A callable MetaDataSpec wrapping the function.
74+
* @returns A callable metadata spec.
3575
*/
36-
export const callable = <M>(fn: (args: unknown[]) => M): MetaDataSpec<M> =>
37-
harden({ kind: 'callable', fn });
76+
export const callable = <M extends Record<string, unknown>>(
77+
fn: (args: unknown[]) => M,
78+
): MetaDataSpec<M> => harden({ kind: 'callable', fn });
3879

3980
/**
4081
* Compile a 'source' spec to 'callable' using the supplied compartment.
@@ -45,7 +86,7 @@ export const callable = <M>(fn: (args: unknown[]) => M): MetaDataSpec<M> =>
4586
* @param compartment.evaluate - Evaluate a JS source string and return the result.
4687
* @returns A ResolvedMetaDataSpec with no 'source' variant.
4788
*/
48-
export const resolveMetaDataSpec = <M>(
89+
export const resolveMetaDataSpec = <M extends Record<string, unknown>>(
4990
spec: MetaDataSpec<M>,
5091
compartment?: { evaluate: (src: string) => unknown },
5192
): ResolvedMetaDataSpec<M> => {
@@ -65,21 +106,22 @@ export const resolveMetaDataSpec = <M>(
65106

66107
/**
67108
* Evaluate a resolved metadata spec against the invocation args.
68-
* Returns undefined if spec is undefined (no metadata on the section).
109+
*
110+
* Missing spec yields `{}` (no metadata). Callable/constant results must be plain objects;
111+
* `undefined`/`null` from the producer normalize to `{}`. Primitives, arrays, and non-plain
112+
* objects throw with guidance to use an explicit record such as `{ value: myValue }`.
69113
*
70114
* @param spec - The resolved spec to evaluate, or undefined.
71115
* @param args - The invocation arguments.
72-
* @returns The evaluated metadata value, or undefined.
116+
* @returns The evaluated metadata object (possibly empty).
73117
*/
74-
export const evaluateMetadata = <M>(
75-
spec: ResolvedMetaDataSpec<M> | undefined,
118+
export const evaluateMetadata = <MetaData extends Record<string, unknown>>(
119+
spec: ResolvedMetaDataSpec<MetaData> | undefined,
76120
args: unknown[],
77-
): M | undefined => {
121+
): MetaData => {
78122
if (spec === undefined) {
79-
return undefined;
80-
}
81-
if (spec.kind === 'constant') {
82-
return spec.value;
123+
return {} as MetaData;
83124
}
84-
return spec.fn(args);
125+
const raw = spec.kind === 'constant' ? spec.value : spec.fn(args);
126+
return normalizeEvaluatedSheafMetadata(raw) as MetaData;
85127
};

packages/kernel-utils/src/sheaf/sheafify.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,43 @@ describe('sheafify', () => {
448448
expect(liftCalled).toBe(false);
449449
});
450450

451+
it('collapses no-metadata and empty-object metadata as equivalent', async () => {
452+
type Meta = Record<string, never>;
453+
let liftCalled = false;
454+
455+
const sections: PresheafSection<Meta>[] = [
456+
{
457+
exo: makeExo(
458+
'Wallet:0',
459+
M.interface('Wallet:0', {
460+
getBalance: M.call(M.string()).returns(M.number()),
461+
}),
462+
{ getBalance: (_acct: string) => 100 },
463+
) as unknown as Section,
464+
},
465+
{
466+
exo: makeExo(
467+
'Wallet:1',
468+
M.interface('Wallet:1', {
469+
getBalance: M.call(M.string()).returns(M.number()),
470+
}),
471+
{ getBalance: (_acct: string) => 42 },
472+
) as unknown as Section,
473+
metadata: constant({}),
474+
},
475+
];
476+
477+
const wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({
478+
lift: async (_germs) => {
479+
liftCalled = true;
480+
return Promise.resolve(0);
481+
},
482+
});
483+
await E(wallet).getBalance('alice');
484+
485+
expect(liftCalled).toBe(false);
486+
});
487+
451488
it('mixed sections participate in lift', async () => {
452489
const argmin: Lift<{ cost: number }> = async (germs) =>
453490
Promise.resolve(

0 commit comments

Comments
 (0)