Skip to content

Commit 2175263

Browse files
committed
feat: support merge in vars
1 parent d0f46f9 commit 2175263

5 files changed

Lines changed: 221 additions & 12 deletions

File tree

packages/experimental/src/fn.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
} from "./error";
88
import {
99
type ApplyOns,
10+
collectMergeSeeds,
1011
collectUsable,
1112
type InputVarExtra,
1213
type InputVarExtraOut,
@@ -43,9 +44,11 @@ import {
4344
type Cells,
4445
contextScope,
4546
type Frame,
47+
getCell,
4648
readVar,
4749
readVarThrough,
4850
type VarDefination,
51+
viewMergeVar,
4952
writeVar,
5053
} from "./var";
5154

@@ -664,6 +667,7 @@ const defineFn = (
664667
for (const mod of modules) scanMembers(mod);
665668

666669
const usable = collectUsable(modules);
670+
const mergeSeeds = collectMergeSeeds(modules);
667671

668672
// A tuple input means POSITIONAL args: the callable takes one arg per
669673
// declared position, then the parent context.
@@ -726,6 +730,15 @@ const defineFn = (
726730
: callArgs[0];
727731
const parent: any = tupleInput ? callArgs[tupleInput.length] : callArgs[1];
728732
const cells: Cells = parent?.[STORE] ?? {};
733+
// Root scope: fold merge-var contributions from `use` into cells
734+
// before anything reads them (storage default + helper namespaces).
735+
if (!parent?.[STORE]) {
736+
for (const [name, value] of Object.entries(mergeSeeds)) {
737+
const cell = getCell(cells, name);
738+
cell.value = value;
739+
cell.accumulate = true;
740+
}
741+
}
729742
// The lock travels the whole subtree: once any frame above is
730743
// readonly, every write below throws - handlers, nested fns,
731744
// interceptors, input-var seeding, all of it.
@@ -886,7 +899,8 @@ const defineFn = (
886899
if (isVar(used)) {
887900
const varName = (used as { name: string }).name;
888901
Object.defineProperty(target, name, {
889-
get: () => readVarThrough(frame, varName),
902+
get: () =>
903+
viewMergeVar(varName, readVarThrough(frame, varName), ctx),
890904
set: (value: unknown) => writeVar(frame, varName, value),
891905
enumerable: true,
892906
configurable: true,

packages/experimental/src/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,12 @@ interface V {
1616
fn: Fn;
1717
var: <N extends LiteralString, S = undefined, D = undefined>(
1818
name: N,
19-
options?: { default?: D; schema?: S },
19+
options?: { default?: D; schema?: S; merge?: boolean },
2020
// A default the schema already covers (e.g. `{}` against an
2121
// all-optional shape) is absorbed; `default: null` still unions in.
22+
// `merge: true` - object contributions from `use` modules (same-name
23+
// defaults, same-key namespaces) shallow-merge onto the value;
24+
// writes accumulate the same way.
2225
) => VarDefination<
2326
N,
2427
[S] extends [undefined]

packages/experimental/src/module.ts

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,10 @@ export const isNamespace = (
265265
* name (`{ cookie: { options: cookieOptions } }` -> `c.cookie.options`
266266
* reads/writes the var). Storage mounts whole (`c.db.user.findOne`).
267267
* Groups holding nothing (however deep) drop out.
268+
*
269+
* A `merge: true` var keeps its slot when a later module exports a
270+
* namespace under the same key - those helpers are folded into the var
271+
* value by {@link collectMergeSeeds}, not bound as a rival namespace.
268272
*/
269273
export const collectUsable = (
270274
modules: readonly Module[],
@@ -274,6 +278,14 @@ export const collectUsable = (
274278
value !== null &&
275279
"$models" in value &&
276280
typeof (value as { $adapter?: unknown }).$adapter === "function";
281+
const isMergeVar = (value: unknown) =>
282+
isVar(value) && (value as { $merge?: boolean }).$merge === true;
283+
const isGroup = (value: unknown) =>
284+
value !== null &&
285+
typeof value === "object" &&
286+
!isFn(value) &&
287+
!isVar(value) &&
288+
!isStorageValue(value);
277289
const walk = (mod: Record<string, unknown>): Record<string, unknown> => {
278290
const out: Record<string, unknown> = {};
279291
for (const [name, value] of Object.entries(mod)) {
@@ -286,11 +298,105 @@ export const collectUsable = (
286298
}
287299
return out;
288300
};
301+
const assign = (
302+
target: Record<string, unknown>,
303+
name: string,
304+
value: unknown,
305+
) => {
306+
const existing = target[name];
307+
if (isMergeVar(existing) && isGroup(value)) return;
308+
if (isMergeVar(value) && isGroup(existing)) {
309+
target[name] = value;
310+
return;
311+
}
312+
if (isGroup(existing) && isGroup(value)) {
313+
const group = { ...(existing as Record<string, unknown>) };
314+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
315+
assign(group, k, v);
316+
}
317+
target[name] = group;
318+
return;
319+
}
320+
target[name] = value;
321+
};
289322
const fns: Record<string, unknown> = {};
290-
for (const mod of resolveModules(modules)) Object.assign(fns, walk(mod));
323+
for (const mod of resolveModules(modules)) {
324+
for (const [name, value] of Object.entries(walk(mod))) {
325+
assign(fns, name, value);
326+
}
327+
}
291328
return fns;
292329
};
293330

331+
/**
332+
* Initial values for `merge: true` vars: walk `use` modules in order and
333+
* shallow-merge (1) each merge-var's `default` and (2) namespaces exported
334+
* under the same key as a merge var. Later leaf keys win.
335+
*/
336+
export const collectMergeSeeds = (
337+
modules: readonly Module[],
338+
): Record<string, unknown> => {
339+
const resolved = resolveModules(modules);
340+
const seeds: Record<string, unknown> = {};
341+
const keyToVar = new Map<string, string>();
342+
343+
const isMergeVar = (
344+
value: unknown,
345+
): value is VarDefination<string, unknown> & {
346+
$merge: true;
347+
default?: unknown;
348+
name: string;
349+
} => isVar(value) && (value as { $merge?: boolean }).$merge === true;
350+
351+
const mergeInto = (name: string, contribution: unknown) => {
352+
if (contribution === undefined) return;
353+
const current = seeds[name];
354+
if (
355+
contribution !== null &&
356+
typeof contribution === "object" &&
357+
!Array.isArray(contribution) &&
358+
current !== null &&
359+
typeof current === "object" &&
360+
!Array.isArray(current)
361+
) {
362+
seeds[name] = {
363+
...(current as Record<string, unknown>),
364+
...(contribution as Record<string, unknown>),
365+
};
366+
} else {
367+
seeds[name] = contribution;
368+
}
369+
};
370+
371+
const index = (mod: Record<string, unknown>) => {
372+
for (const [key, value] of Object.entries(mod)) {
373+
if (isMergeVar(value)) {
374+
keyToVar.set(key, value.name);
375+
} else if (isNamespace(value)) {
376+
index(value);
377+
}
378+
}
379+
};
380+
for (const mod of resolved) index(mod);
381+
382+
const contribute = (mod: Record<string, unknown>) => {
383+
for (const [key, value] of Object.entries(mod)) {
384+
if (isMergeVar(value)) {
385+
mergeInto(value.name, value.default);
386+
continue;
387+
}
388+
if (isNamespace(value)) {
389+
const varName = keyToVar.get(key);
390+
if (varName) mergeInto(varName, value);
391+
else contribute(value);
392+
}
393+
}
394+
};
395+
for (const mod of resolved) contribute(mod);
396+
397+
return seeds;
398+
};
399+
294400
export const isOn = (value: any): value is OnEntry<string> =>
295401
value?.$on === true;
296402

packages/experimental/src/var.test.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, expectTypeOf, it } from "vitest";
2-
import { v } from "./index";
2+
import { memoryAdapter, v } from "./index";
33

44
describe("vars", () => {
55
const note = v.var("vt_note", { default: "" });
@@ -124,3 +124,49 @@ describe("record vars", () => {
124124
await expect(entry()).resolves.toEqual({ title: "hi", body: "there" });
125125
});
126126
});
127+
128+
describe("merge vars", () => {
129+
it("use modules merge helpers onto a storage default under the same key", async () => {
130+
const row = v.var("vt_merge_row", {
131+
default: null,
132+
schema: v.object({ id: v.string(), tag: v.string() }),
133+
});
134+
const store = v.storage(memoryAdapter(), { row });
135+
const db = v.var("vt_merge_db", {
136+
merge: true,
137+
default: store,
138+
});
139+
const byTag = v.fn(
140+
"vt.merge.byTag",
141+
{
142+
input: { tag: v.string() },
143+
use: [{ db, row }],
144+
},
145+
async (c) => c.vt_merge_db.row.findMany({ tag: c.input.tag }),
146+
);
147+
const core = { db, row };
148+
const plugin = { db: { byTag } };
149+
const entry = v.fn({ use: [core, plugin] }, async (c) => {
150+
await c.vt_merge_db.row.create({ id: "1", tag: "a" });
151+
await c.vt_merge_db.row.create({ id: "2", tag: "b" });
152+
return c.vt_merge_db.byTag({ tag: "a" });
153+
});
154+
await expect(entry()).resolves.toEqual([{ id: "1", tag: "a" }]);
155+
});
156+
157+
it("later use module wins on a conflicting helper key", async () => {
158+
const base = v.var("vt_merge_obj", {
159+
merge: true,
160+
default: { n: 0, from: "base" },
161+
});
162+
const first = v.fn("vt.merge.first", () => "first");
163+
const second = v.fn("vt.merge.second", () => "second");
164+
const entry = v.fn(
165+
{
166+
use: [{ obj: base }, { obj: { who: first } }, { obj: { who: second } }],
167+
},
168+
(c) => c.vt_merge_obj.who(),
169+
);
170+
expect(entry()).toBe("second");
171+
});
172+
});

packages/experimental/src/var.ts

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ export interface VarDefination<
3131
$source?: Source;
3232
/** Whole-var plugin attributes (field attrs live on the schema). */
3333
$attrs?: AttrBag;
34+
/**
35+
* Object contributions from `use` modules merge instead of replacing:
36+
* same-name var defaults and same-export-key namespaces shallow-merge
37+
* onto this value (storage default + helpers). Writes accumulate too.
38+
*/
39+
$merge?: boolean;
3440
customize: <S>(options: {
3541
schema: (v: VarCustomizer<T>) => S;
3642
}) => VarDefination<N, InferInput<S>, S>;
@@ -74,6 +80,7 @@ export const makeVar = (name: string, options: any = {}): any => {
7480
schema,
7581
...(options.attrs !== undefined ? { $attrs: options.attrs } : {}),
7682
$accessor: options.accessor === true,
83+
$merge: options.merge === true,
7784
$derive: options.derive,
7885
customize: (opts: any) =>
7986
makeVar(name, {
@@ -117,7 +124,7 @@ export type Cell = {
117124
derive?: { source: string; get: (value: any) => any };
118125
/** A direct write to a derived var shadows its computation. */
119126
shadowed: boolean;
120-
/** Record var: `set()` merges instead of replacing. */
127+
/** Record / merge var: `set()` merges instead of replacing. */
121128
accumulate: boolean;
122129
};
123130

@@ -132,7 +139,7 @@ export const getCell = (cells: Cells, name: string): Cell => {
132139
value: def?.$derive ? undefined : def?.$accessor ? {} : def?.default,
133140
derive: def?.$derive,
134141
shadowed: false,
135-
accumulate: def?.$accessor === true,
142+
accumulate: def?.$accessor === true || def?.$merge === true,
136143
};
137144
cells[name] = cell;
138145
return cell;
@@ -274,6 +281,38 @@ export const readVarThrough = (frame: Frame, name: string): unknown => {
274281

275282
/* ---------------------------------- scope ---------------------------------- */
276283

284+
/**
285+
* Merge-var reads expose a shallow view that binds any `$fn` member into
286+
* this context - helpers contributed via `use` call like usable fns.
287+
*/
288+
export const viewMergeVar = (
289+
name: string,
290+
value: unknown,
291+
ctx: unknown,
292+
): unknown => {
293+
const def = varRegistry.get(name);
294+
if (!def?.$merge || value == null || typeof value !== "object") return value;
295+
return new Proxy(value as object, {
296+
get: (t, prop, receiver) => {
297+
const member = Reflect.get(t, prop, receiver);
298+
if (
299+
typeof member !== "function" ||
300+
(member as { $fn?: boolean }).$fn !== true
301+
) {
302+
return member;
303+
}
304+
const usedArity = (member as { $arity?: number }).$arity;
305+
return usedArity === undefined
306+
? (i?: unknown) => (member as any)(i, ctx)
307+
: (...args: unknown[]) => {
308+
const padded = args.slice(0, usedArity);
309+
while (padded.length < usedArity) padded.push(undefined);
310+
return (member as any)(...padded, ctx);
311+
};
312+
},
313+
});
314+
};
315+
277316
/**
278317
* The fn context for one frame: `base` carries the fixed surface (input,
279318
* error, fn, types, the bound `use` fns, internal symbols) and EVERY
@@ -285,12 +324,13 @@ export const readVarThrough = (frame: Frame, name: string): unknown => {
285324
*/
286325
export const contextScope = (frame: Frame, base: object): any =>
287326
new Proxy(base, {
288-
get: (t, prop, receiver) =>
289-
prop in t
290-
? Reflect.get(t, prop, receiver)
291-
: typeof prop === "string"
292-
? readVarThrough(frame, prop)
293-
: undefined,
327+
get: (t, prop, receiver) => {
328+
if (prop in t) return Reflect.get(t, prop, receiver);
329+
if (typeof prop === "string") {
330+
return viewMergeVar(prop, readVarThrough(frame, prop), receiver);
331+
}
332+
return undefined;
333+
},
294334
set: (t, prop, value, receiver) => {
295335
if (typeof prop !== "string" || prop in t) {
296336
return Reflect.set(t, prop, value, receiver);

0 commit comments

Comments
 (0)