Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions packages/discovery/src/builders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,19 @@ export function anonFunct(props: {
});
}

export function wrapExprInFunct(expr: Expression) {
return paren({
type: "ArrowFunctionExpression",
params: [],
body: expr,
expression: true,
async: false,
id: null,
generator: false,
...defaultSpan,
});
}

export function throwStmt(toThrow: Expression): ThrowStatement {
return {
type: "ThrowStatement",
Expand Down
6 changes: 2 additions & 4 deletions packages/discovery/src/transpile/api.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { unwrap } from "@vortexjs/common";
import type { CallExpression, Expression } from "oxc-parser";
import { anonFunct, createObject, defaultSpan, exportNode, identifier, literal } from "../builders";
import { anonFunct, createObject, defaultSpan, exportNode, identifier, literal, wrapExprInFunct } from "../builders";
import {
type CompilerState,
getObjectKeys,
Expand Down Expand Up @@ -56,12 +56,10 @@ export function handleAPIFunction(

if (state.target === "server") {
const implExportId = exportNode(state, impl);
props.impl = identifier(implExportId);
props.impl = wrapExprInFunct(identifier(implExportId));

const schemaExportId = exportNode(state, schema);

props.schema = identifier(schemaExportId);

state.clientEligible = false;
state.discoveries.push({
type: "api",
Expand Down
11 changes: 10 additions & 1 deletion packages/example-wormhole/src/features/home/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ route("/", {
}
})

const currentTime = time.use({});

return (
<>
<DOMKeyboardActions />
Expand All @@ -22,7 +24,7 @@ route("/", {
</h1>
<p>
This is an example app, go to the{" "}
<a href="/docs/tada">docs</a>
<a href="/docs/tada">docs</a>, current time is {currentTime}
</p>
<button on:click={async () => {
console.log(await add({
Expand Down Expand Up @@ -73,3 +75,10 @@ export const add = query("/api/add", {
return a + b;
}
})

export const time = query("/api/time", {
impl() {
return new Date().toISOString();
},
schema: v.object({})
});
1 change: 1 addition & 0 deletions packages/vortex-common/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
42 changes: 42 additions & 0 deletions packages/vortex-common/src/time.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
13 changes: 12 additions & 1 deletion packages/vortex-core/src/context.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
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";

Expand Down Expand Up @@ -97,10 +99,19 @@ export class StreamingContext {
export class ContextScope {
contexts: Record<string, Signal<any>> = {};
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();
const newScope = new ContextScope(this.lt);
newScope.contexts = { ...this.contexts };
newScope.streaming = this.streaming;
newScope.query = this.query;
return newScope;
}

Expand Down
1 change: 1 addition & 0 deletions packages/vortex-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
137 changes: 137 additions & 0 deletions packages/vortex-core/src/query/data-engine.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { getImmediateValue, 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<Args, Result> {
maxAge: number;
query: Query<Args, Result>;

constructor(
props: {
query: Query<Args, Result>;
maxAge?: number;
}
) {
this.query = props.query;
this.maxAge = props.maxAge ?? time("5m").ms;
}
}

export type QuerySupplement = Record<string, any>;

export class Query<Args, Result> {
schema: QuerySchema<Args, Result>;
args: Args;
data: Store<Result | undefined> = useState(undefined);
isLoading: boolean;
updatedAt: number;
dataEngine: QueryDataEngine;

constructor(props: { schema: QuerySchema<Args, Result>; args: Args, dataEngine: QueryDataEngine }) {
this.schema = props.schema;
this.args = props.args;
this.isLoading = false;
this.updatedAt = 0;
this.dataEngine = props.dataEngine;
}

async update() {
if (this.isLoading) return;

using _data = this.dataEngine.streaming.markLoading();

this.isLoading = true;

try {
const result = await this.schema.impl(this.args);
this.data.set(result);
this.updatedAt = Date.now();
console.log(`[${this.dataEngine.id}]: Query updated:`, this.schema.getKey(this.args), result);
} catch (e) {
console.error("Error fetching query:", e);
} finally {
this.isLoading = false;
}
}
}

export class QueryDataEngine {
observations: QueryObservation<any, any>[] = [];
queries = new Map<string, Query<any, any>>();
streaming: StreamingContext;
hydrationSupplement: QuerySupplement = {};
id = crypto.randomUUID();

constructor({ streaming, lt }: {
streaming: StreamingContext,
lt: Lifetime
}) {
this.streaming = streaming;
const int = setInterval(() => this.tick(), 1000);
lt.onClosed(() => clearInterval(int));
}

getSupplement(): QuerySupplement {
console.log(`[${this.id}]: I have ${this.queries.size} queries`);
const supplement: QuerySupplement = {};
for (const [key, query] of this.queries) {
const data = getImmediateValue(query.data);
console.log(`[${this.id}]: Gathering supplement data for ${key}`, data);
if (data === undefined) continue;
supplement[key] = data;
}
return supplement;
}

tick() {
const now = Date.now();

for (const obs of this.observations) {
if (now - obs.query.updatedAt > obs.maxAge) {
obs.query.update();
}
}
}

createObservation<Args, Result>(
props: {
maxAge?: number;
schema: QuerySchema<Args, Result>;
args: Args;
lt: Lifetime;
}
): QueryObservation<Args, Result> {
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<Args, Result>({ schema: props.schema, args: props.args, dataEngine: this });
this.queries.set(key, query);
if (key in this.hydrationSupplement) {
query.data.set(this.hydrationSupplement[key]);
query.updatedAt = Date.now();
} else {
query.update();
}
}

const observation = new QueryObservation<Args, Result>({
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;
}
}
24 changes: 24 additions & 0 deletions packages/vortex-core/src/query/hook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { useContextScope } from "~/context";
import type { QuerySchema } from "./schema";
import { useHookLifetime } from "~/lifetime";
import type { Signal } from "~/signal";

export interface QueryProps { maxAge?: number }

export function useQuery<Args, Result>(
schema: QuerySchema<Args, Result>,
args: Args,
props?: QueryProps,
): Signal<Result | undefined> {
const contextScope = useContextScope();
const lt = useHookLifetime();

const observation = contextScope.query.createObservation({
schema,
args,
lt,
maxAge: props?.maxAge,
});

return observation.query.data;
}
3 changes: 3 additions & 0 deletions packages/vortex-core/src/query/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from "./data-engine";
export * from "./hook";
export * from "./schema";
22 changes: 22 additions & 0 deletions packages/vortex-core/src/query/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { hash } from "@vortexjs/common";

export interface QuerySchema<Args, Result> {
id: string;
impl(args: Args): Promise<Result> | Result;
getKey(args: Args): string;
}

export function querySchema<Args, Result>({ id, impl }: {
id: string;
impl(args: Args): Promise<Result> | Result;
}): QuerySchema<Args, Result> {
return {
id,
impl,
getKey(args) {
const base = `${id}::${JSON.stringify(args)}`;
const short = hash(base).toString(36).slice(0, 5);
return short;
}
};
}
2 changes: 1 addition & 1 deletion packages/vortex-core/src/render/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ function internalRender<RendererNode, HydrationContext>({ renderer, root, compon
node: <ActionProvider>{component}</ActionProvider>,
hydration: renderer.getHydrationContext(root),
lt,
context: context ?? ContextScope.current ?? new ContextScope(),
context: context ?? ContextScope.current ?? new ContextScope(lt),
});

const portal = new FLPortal(root, renderer);
Expand Down
9 changes: 7 additions & 2 deletions packages/wormhole/src/build/adapters/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,13 @@ export function DevAdapter(): DevAdapter {
root,
pathname: props.pathname,
context: props.context,
lifetime: props.lifetime ?? new Lifetime(),
});`;
lifetime: props.lifetime ?? new Lifetime(),`;

if (location === "client") {
codegenSource += `supplement: props.supplement,`;
}

codegenSource += `});`;

codegenSource += `}`;

Expand Down
Loading