From 5220bf1cc3d02e726652a01a62f988b4ad923a1e Mon Sep 17 00:00:00 2001 From: andylovescode Date: Sun, 24 Aug 2025 14:51:06 -0700 Subject: [PATCH 1/4] Core: Add first draft of Query API --- packages/vortex-common/src/index.ts | 1 + packages/vortex-common/src/time.ts | 42 +++ packages/vortex-core/src/context.ts | 262 +++++++++--------- packages/vortex-core/src/index.ts | 1 + packages/vortex-core/src/query/data-engine.ts | 115 ++++++++ packages/vortex-core/src/query/hook.ts | 22 ++ packages/vortex-core/src/query/index.ts | 2 + packages/vortex-core/src/query/schema.ts | 16 ++ packages/vortex-core/src/render/index.tsx | 2 +- .../wormhole/src/build/adapters/vercel.ts | 2 +- packages/wormhole/src/dev/dev-server.ts | 3 +- 11 files changed, 338 insertions(+), 130 deletions(-) create mode 100644 packages/vortex-common/src/time.ts create mode 100644 packages/vortex-core/src/query/data-engine.ts create mode 100644 packages/vortex-core/src/query/hook.ts create mode 100644 packages/vortex-core/src/query/index.ts create mode 100644 packages/vortex-core/src/query/schema.ts diff --git a/packages/vortex-common/src/index.ts b/packages/vortex-common/src/index.ts index b275b3f..f2c4f41 100644 --- a/packages/vortex-common/src/index.ts +++ b/packages/vortex-common/src/index.ts @@ -7,6 +7,7 @@ export * from "./hash"; export * from "./npm"; export * as SKL from "./skl"; export * from "./smolify"; +export * from "./time"; export * from "./ultraglobal"; export * from "./unreachable"; export * from "./unwrap"; diff --git a/packages/vortex-common/src/time.ts b/packages/vortex-common/src/time.ts new file mode 100644 index 0000000..fdb0e37 --- /dev/null +++ b/packages/vortex-common/src/time.ts @@ -0,0 +1,42 @@ +const timeUnits = { + s: 1000, + ms: 1, + m: 60 * 1000, + h: 60 * 60 * 1000, + d: 24 * 60 * 60 * 1000, + w: 7 * 24 * 60 * 60 * 1000, + y: 365 * 24 * 60 * 60 * 1000, +} as const; + +type TimeUnit = keyof typeof timeUnits; + +export type TimeSpec = `${number}${TimeUnit}`; + +export function time(time: TimeSpec): { ms: number } { + let numberPortion = ""; + let unitPortion = ""; + + for (const char of time) { + if (char >= "0" && char <= "9") { + if (unitPortion.length > 0) { + throw new Error(`Invalid time spec: ${time}`); + } + numberPortion += char; + } else { + unitPortion += char; + } + } + + if (numberPortion.length === 0 || unitPortion.length === 0) { + throw new Error(`Invalid time spec: ${time}`); + } + + const numberValue = Number.parseInt(numberPortion, 10); + const unitValue = timeUnits[unitPortion as TimeUnit]; + + if (Number.isNaN(numberValue) || unitValue === undefined) { + throw new Error(`Invalid time spec: ${time}`); + } + + return { ms: numberValue * unitValue }; +} diff --git a/packages/vortex-core/src/context.ts b/packages/vortex-core/src/context.ts index 233e9a9..609821f 100644 --- a/packages/vortex-core/src/context.ts +++ b/packages/vortex-core/src/context.ts @@ -1,154 +1,164 @@ import { unwrap } from "@vortexjs/common"; import type { JSXNode } from "./jsx/jsx-common"; +import type { Lifetime } from "./lifetime"; +import { QueryDataEngine } from "./query"; import { clearImmediate, setImmediate } from "./setImmediate.polyfill"; import { type Signal, type SignalOrValue, toSignal } from "./signal"; export interface Context { - (props: { value: SignalOrValue; children: JSXNode }): JSXNode; - use(): Signal; - useOptional(): Signal | undefined; + (props: { value: SignalOrValue; children: JSXNode }): JSXNode; + use(): Signal; + useOptional(): Signal | undefined; } export function createContext(name = "Unnamed"): Context { - const id = crypto.randomUUID(); - - const result = (props: { - value: SignalOrValue; - children: JSXNode; - }): JSXNode => { - return { - type: "context", - id, - value: toSignal(props.value), - children: props.children, - }; - }; - - result.use = () => { - return unwrap( - useContextScope().contexts[id], - `Context "${name}" not found, you may have forgotten to wrap your component in the context provider.`, - ); - }; - - result.useOptional = () => { - return useContextScope().contexts[id]; - }; - - return result; + const id = crypto.randomUUID(); + + const result = (props: { + value: SignalOrValue; + children: JSXNode; + }): JSXNode => { + return { + type: "context", + id, + value: toSignal(props.value), + children: props.children, + }; + }; + + result.use = () => { + return unwrap( + useContextScope().contexts[id], + `Context "${name}" not found, you may have forgotten to wrap your component in the context provider.`, + ); + }; + + result.useOptional = () => { + return useContextScope().contexts[id]; + }; + + return result; } export class StreamingContext { - private updateCallbackImmediate = 0; - private updateCallbacks = new Set<() => void>(); - private loadingCounter = 0; - private onDoneLoadingCallback = () => {}; - onDoneLoading: Promise; - - constructor() { - this.onDoneLoading = new Promise((resolve) => { - this.onDoneLoadingCallback = resolve; - }); - } - - onUpdate(callback: () => void): () => void { - this.updateCallbacks.add(callback); - - return () => { - this.updateCallbacks.delete(callback); - }; - } - - markLoading() { - const self = this; - - this.loadingCounter++; - - return { - [Symbol.dispose]() { - self.loadingCounter--; - self.updated(); - }, - }; - } - - updated() { - if (this.updateCallbackImmediate) { - clearImmediate(this.updateCallbackImmediate); - } - - // biome-ignore lint/complexity/noUselessThisAlias: without it, shit breaks - const self = this; - - this.updateCallbackImmediate = setImmediate(() => { - self.updateCallbackImmediate = 0; - - for (const callback of self.updateCallbacks) { - callback(); - } - - if (self.loadingCounter === 0) { - self.onDoneLoadingCallback(); - } - }) as unknown as number; - } + private updateCallbackImmediate = 0; + private updateCallbacks = new Set<() => void>(); + private loadingCounter = 0; + private onDoneLoadingCallback = () => { }; + onDoneLoading: Promise; + + constructor() { + this.onDoneLoading = new Promise((resolve) => { + this.onDoneLoadingCallback = resolve; + }); + } + + onUpdate(callback: () => void): () => void { + this.updateCallbacks.add(callback); + + return () => { + this.updateCallbacks.delete(callback); + }; + } + + markLoading() { + const self = this; + + this.loadingCounter++; + + return { + [Symbol.dispose]() { + self.loadingCounter--; + self.updated(); + }, + }; + } + + updated() { + if (this.updateCallbackImmediate) { + clearImmediate(this.updateCallbackImmediate); + } + + // biome-ignore lint/complexity/noUselessThisAlias: without it, shit breaks + const self = this; + + this.updateCallbackImmediate = setImmediate(() => { + self.updateCallbackImmediate = 0; + + for (const callback of self.updateCallbacks) { + callback(); + } + + if (self.loadingCounter === 0) { + self.onDoneLoadingCallback(); + } + }) as unknown as number; + } } export class ContextScope { - contexts: Record> = {}; - streaming: StreamingContext = new StreamingContext(); - - fork() { - const newScope = new ContextScope(); - newScope.contexts = { ...this.contexts }; - return newScope; - } - - addContext(id: string, value: Signal): void { - this.contexts[id] = value; - } - - static current: ContextScope | null = null; - - static setCurrent(scope: ContextScope | null) { - const previous = ContextScope.current; - - ContextScope.current = scope; - - return { - [Symbol.dispose]() { - ContextScope.current = previous; - }, - }; - } + contexts: Record> = {}; + streaming: StreamingContext = new StreamingContext(); + query: QueryDataEngine; + lt: Lifetime; + + constructor(lt: Lifetime) { + this.query = new QueryDataEngine({ streaming: this.streaming, lt }); + this.lt = lt; + } + + fork() { + const newScope = new ContextScope(this.lt); + newScope.contexts = { ...this.contexts }; + newScope.streaming = this.streaming; + return newScope; + } + + addContext(id: string, value: Signal): void { + this.contexts[id] = value; + } + + static current: ContextScope | null = null; + + static setCurrent(scope: ContextScope | null) { + const previous = ContextScope.current; + + ContextScope.current = scope; + + return { + [Symbol.dispose]() { + ContextScope.current = previous; + }, + }; + } } export function useContextScope(): ContextScope { - const scope = ContextScope.current; - if (!scope) { - throw new Error( - "No context scope found, you should have one if you're rendering a component.", - ); - } - return scope; + const scope = ContextScope.current; + if (!scope) { + throw new Error( + "No context scope found, you should have one if you're rendering a component.", + ); + } + return scope; } export function useStreaming(): StreamingContext { - return useContextScope().streaming; + return useContextScope().streaming; } export function useOptionalContextScope(): ContextScope | null { - const scope = ContextScope.current; - if (!scope) { - return null; - } - return scope; + const scope = ContextScope.current; + if (!scope) { + return null; + } + return scope; } export function useOptionalStreaming(): StreamingContext | null { - const scope = useOptionalContextScope(); - if (!scope) { - return null; - } - return scope.streaming; + const scope = useOptionalContextScope(); + if (!scope) { + return null; + } + return scope.streaming; } diff --git a/packages/vortex-core/src/index.ts b/packages/vortex-core/src/index.ts index dbbcc98..848dc9a 100644 --- a/packages/vortex-core/src/index.ts +++ b/packages/vortex-core/src/index.ts @@ -2,6 +2,7 @@ export * from "./context"; export * from "./intrinsic"; export * from "./jsx/jsx-common"; export * from "./lifetime"; +export * from "./query"; export * from "./render"; export * from "./setImmediate.polyfill"; export * from "./signal"; diff --git a/packages/vortex-core/src/query/data-engine.ts b/packages/vortex-core/src/query/data-engine.ts new file mode 100644 index 0000000..5cd8145 --- /dev/null +++ b/packages/vortex-core/src/query/data-engine.ts @@ -0,0 +1,115 @@ +import { useState, type Store } from "~/signal"; +import type { QuerySchema } from "./schema"; +import { time } from "@vortexjs/common"; +import type { StreamingContext } from "~/context"; +import type { Lifetime } from "~/lifetime"; + +export class QueryObservation { + maxAge: number; + query: Query; + + constructor( + props: { + query: Query; + maxAge?: number; + } + ) { + this.query = props.query; + this.maxAge = props.maxAge ?? time("5m").ms; + } +} + +export class Query { + schema: QuerySchema; + args: Args; + data: Store = useState(undefined); + isLoading: boolean; + updatedAt: number; + streaming: StreamingContext; + + constructor(props: { schema: QuerySchema; args: Args, streaming: StreamingContext }) { + this.schema = props.schema; + this.args = props.args; + this.isLoading = false; + this.updatedAt = 0; + this.streaming = props.streaming; + } + + async update() { + if (this.isLoading) return; + + using _data = this.streaming.markLoading(); + + this.isLoading = true; + + try { + const result = await this.schema.impl(this.args); + this.data.set(result); + this.updatedAt = Date.now(); + } catch (e) { + console.error("Error fetching query:", e); + } finally { + this.isLoading = false; + } + } +} + +export class QueryDataEngine { + observations: QueryObservation[] = []; + queries = new Map>(); + streaming: StreamingContext; + + constructor({ streaming, lt }: { + streaming: StreamingContext, + lt: Lifetime + }) { + this.streaming = streaming; + const int = setInterval(() => this.tick(), 1000); + lt.onClosed(() => clearInterval(int)); + } + + tick() { + const now = Date.now(); + + for (const obs of this.observations) { + if (now - obs.query.updatedAt > obs.maxAge) { + obs.query.update(); + } + } + } + + createObservation( + props: { + maxAge?: number; + schema: QuerySchema; + args: Args; + lt: Lifetime; + } + ): QueryObservation { + const key = props.schema.getKey(props.args); + + // Create query if it doesn't exist + let query = this.queries.get(key); + + if (!query) { + query = new Query({ schema: props.schema, args: props.args, streaming: this.streaming }); + this.queries.set(key, query); + query.update(); + } + + const observation = new QueryObservation({ + query, + maxAge: props.maxAge + }); + this.observations.push(observation); + + props.lt.onClosed(() => { + const index = this.observations.indexOf(observation); + if (index !== -1) { + this.observations.splice(index, 1); + } + }); + + return observation; + } +} diff --git a/packages/vortex-core/src/query/hook.ts b/packages/vortex-core/src/query/hook.ts new file mode 100644 index 0000000..003f34d --- /dev/null +++ b/packages/vortex-core/src/query/hook.ts @@ -0,0 +1,22 @@ +import { useContextScope } from "~/context"; +import type { QuerySchema } from "./schema"; +import { useHookLifetime } from "~/lifetime"; +import type { Signal } from "~/signal"; + +export function useQuery( + schema: QuerySchema, + args: Args, + props?: { maxAge?: number }, +): Signal { + const contextScope = useContextScope(); + const lt = useHookLifetime(); + + const observation = contextScope.query.createObservation({ + schema, + args, + lt, + maxAge: props?.maxAge, + }); + + return observation.query.data; +} diff --git a/packages/vortex-core/src/query/index.ts b/packages/vortex-core/src/query/index.ts new file mode 100644 index 0000000..3aa3228 --- /dev/null +++ b/packages/vortex-core/src/query/index.ts @@ -0,0 +1,2 @@ +export * from "./data-engine"; +export * from "./hook"; diff --git a/packages/vortex-core/src/query/schema.ts b/packages/vortex-core/src/query/schema.ts new file mode 100644 index 0000000..fe5dd2c --- /dev/null +++ b/packages/vortex-core/src/query/schema.ts @@ -0,0 +1,16 @@ +export interface QuerySchema { + id: string; + impl(args: Args): Promise | Result; + getKey(args: Args): string; +} + +export function querySchema({ id, impl }: { + id: string; + impl(args: Args): Promise | Result; +}): QuerySchema { + return { + id, impl, getKey(args) { + return `${id}::${JSON.stringify(args)}` + } + }; +} diff --git a/packages/vortex-core/src/render/index.tsx b/packages/vortex-core/src/render/index.tsx index 56e248f..eaede6a 100644 --- a/packages/vortex-core/src/render/index.tsx +++ b/packages/vortex-core/src/render/index.tsx @@ -50,7 +50,7 @@ function internalRender({ renderer, root, compon node: {component}, hydration: renderer.getHydrationContext(root), lt, - context: context ?? ContextScope.current ?? new ContextScope(), + context: context ?? ContextScope.current ?? new ContextScope(lt), }); const portal = new FLPortal(root, renderer); diff --git a/packages/wormhole/src/build/adapters/vercel.ts b/packages/wormhole/src/build/adapters/vercel.ts index 1f7e635..520841c 100644 --- a/packages/wormhole/src/build/adapters/vercel.ts +++ b/packages/wormhole/src/build/adapters/vercel.ts @@ -233,7 +233,7 @@ export function VercelAdapter(): VercelAdapter { codegenSource += `const renderer = ssr();`; codegenSource += `const root = createHTMLRoot();`; codegenSource += `const lifetime = new Lifetime();`; - codegenSource += `const context = new ContextScope();`; + codegenSource += `const context = new ContextScope(lifetime);`; codegenSource += `await INTERNAL_entrypoint({ props: entrypointProps, loaders, diff --git a/packages/wormhole/src/dev/dev-server.ts b/packages/wormhole/src/dev/dev-server.ts index 3e93367..8c79e9d 100644 --- a/packages/wormhole/src/dev/dev-server.ts +++ b/packages/wormhole/src/dev/dev-server.ts @@ -130,9 +130,8 @@ async function DevServer_processRequest(this: DevServer, request: Request, tags: const root = createHTMLRoot(); const renderer = ssr(); - const context = new ContextScope(); - const lifetime = new Lifetime(); + const context = new ContextScope(lifetime); serverEntrypoint.main({ root, From 322a53e4e6cd1a7993be8608cbd83e27f7c6446e Mon Sep 17 00:00:00 2001 From: andylovescode Date: Sun, 24 Aug 2025 15:04:04 -0700 Subject: [PATCH 2/4] Wormhole: Initial support for Query API --- .../src/features/home/index.tsx | 11 +- packages/vortex-core/src/query/hook.ts | 4 +- packages/vortex-core/src/query/index.ts | 1 + packages/wormhole/src/runtime/api.ts | 233 ++++++++++-------- packages/wormhole/src/virt/route.ts | 29 ++- 5 files changed, 167 insertions(+), 111 deletions(-) diff --git a/packages/example-wormhole/src/features/home/index.tsx b/packages/example-wormhole/src/features/home/index.tsx index 15f08e4..6b70d3b 100644 --- a/packages/example-wormhole/src/features/home/index.tsx +++ b/packages/example-wormhole/src/features/home/index.tsx @@ -14,6 +14,8 @@ route("/", { } }) + const currentTime = time.use({}, { maxAge: 500 }); + return ( <> @@ -22,7 +24,7 @@ route("/", {

This is an example app, go to the{" "} - docs + docs, current time is {currentTime}