@aletso/atom-query
v0.1.5
Published
Effect atom query layer with SWR, optimistic updates, and SSR preloading
Downloads
367
Readme
@aletso/atom-query
Query and mutation primitives for Effect based apps that use route contracts from @aletso/effect-route.
This package is commonly used in layered data modules where:
RouteandRouteGroupdefine API contracts- client data modules build query atoms and mutation triggers
- server data modules build SSR preloaders
- route loaders call
.ensure(...)or.prefetch(...)on the right side (server or client)
Install
pnpm add @aletso/atom-query @aletso/effect-route @effect-atom/atom @effect/rpc effect1) Define contracts once with @aletso/effect-route
import * as Route from "@aletso/effect-route/Route";
import * as RouteGroup from "@aletso/effect-route/RouteGroup";
import * as Schema from "effect/Schema";
export const SurveyListItem = Schema.Struct({
id: Schema.String,
title: Schema.String,
});
export class SurveyNotFound extends Schema.TaggedError<SurveyNotFound>()(
"SurveyNotFound",
{
id: Schema.String,
},
) {}
export class Group extends RouteGroup.make(
Route.make("find_many", {
success: Schema.Array(SurveyListItem),
}),
Route.make("find_by_id", {
success: SurveyListItem,
error: SurveyNotFound,
payload: Schema.Struct({ id: Schema.String }),
}),
).prefix("survey_") {}2) Client data module
Example client data module.
import * as Atom from "@effect-atom/atom/Atom";
import * as Mutation from "@aletso/atom-query/Mutation";
import * as Query from "@aletso/atom-query/Query";
import * as Survey from "../domain/api/Survey";
import { SurveyApi } from "../client/api/survey";
import * as Effect from "effect/Effect";
const runtime = Atom.runtime(SurveyApi.Default);
export const surveyFindMany = Query.fromRoute(Survey.Group.find_many, {
effect: Effect.gen(function* () {
const api = yield* SurveyApi;
return yield* api.findMany();
}),
runtime,
});
export const surveyFindById = Query.familyFromRoute(Survey.Group.find_by_id, {
effect: (payload) =>
Effect.gen(function* () {
const api = yield* SurveyApi;
return yield* api.findById(payload.id);
}),
runtime,
});
export const surveyDelete = Mutation.fromRoute(Survey.Group.delete, {
effect: Effect.fnUntraced(function* (payload) {
const api = yield* SurveyApi;
return yield* api.delete(payload.id);
}),
runtime,
});3) Server data module for SSR preload
Example server data module for SSR preloading.
import * as Query from "@aletso/atom-query/Query";
import * as Survey from "../domain/api/Survey";
import { SurveyService } from "../server/public/survey/service";
import * as Effect from "effect/Effect";
export const surveyFindManyServer = Query.serverFromRoute(
Survey.Group.find_many,
{
effect: Effect.flatMap(SurveyService, (s) => s.findMany),
},
);
export const surveyFindByIdServer = Query.serverFamilyFromRoute(
Survey.Group.find_by_id,
{
effect: (payload) =>
Effect.flatMap(SurveyService, (s) => s.findById(payload.id)),
},
);4) Isomorphic route loaders with .ensure(...) and .prefetch(...)
Example isomorphic loader usage.
import { createIsomorphicFn } from "@tanstack/react-start";
import {
surveyFindById,
surveyFindByIdServer,
surveyFindManyServer,
} from "../data/survey";
import { serverRuntime } from "../routes/api/rpc/$";
export const ensureSurvey = createIsomorphicFn()
.server((context, id: string) =>
surveyFindByIdServer.ensure(context.registry, serverRuntime, { id }),
)
.client((context, id: string) =>
surveyFindById.ensure(context.registry, { id }),
);
export const prefetchSurveyList = createIsomorphicFn().server((context) =>
surveyFindManyServer.prefetch(context.registry, serverRuntime),
);5) SWR and optimistic cache updates
Example optimistic cache update flow.
import { Atom } from "@effect-atom/atom-react";
import * as Query from "@aletso/atom-query/Query";
import * as Arr from "effect/Array";
import * as Data from "effect/Data";
import * as Option from "effect/Option";
type Survey = { id: string; title: string };
export type SurveyCacheUpdate = Data.TaggedEnum<{
Delete: { readonly id: string };
Upsert: { readonly item: Survey };
}>;
const remoteAtom = surveyFindMany.pipe(Atom.setIdleTTL(0));
export const surveyList = Query.lazyOptimistic(
remoteAtom,
(current, update: SurveyCacheUpdate) => {
switch (update._tag) {
case "Delete":
return Arr.filter(current, (s) => s.id !== update.id);
case "Upsert": {
const index = Arr.findFirstIndex(
current,
(s) => s.id === update.item.id,
);
return Option.match(index, {
onNone: () => Arr.prepend(current, update.item),
onSome: (i) => Arr.replace(current, i, update.item),
});
}
}
},
).pipe(Atom.setIdleTTL(300_000), Query.swr({ staleTime: 60_000 }));
// Force next read to revalidate
surveyList.invalidate();6) Request header propagation for middleware aware queries
CurrentHeaders lets server queries read headers from effect context when explicit headers are not passed.
import { CurrentHeaders } from "@aletso/atom-query/RequestHeaders";
import * as Effect from "effect/Effect";
import * as ManagedRuntime from "effect/ManagedRuntime";
import { getRequestHeaders } from "@tanstack/react-start/server";
const makeServerRuntime = (layer: any, memoMap: any) => {
const raw = ManagedRuntime.make(layer, memoMap);
return new Proxy(raw, {
get(target, prop) {
const value = Reflect.get(target, prop, target);
if (
(prop === "runPromise" || prop === "runPromiseExit") &&
typeof value === "function"
) {
return (
effect: Effect.Effect<any, any, any>,
...rest: ReadonlyArray<any>
) => {
let headers: Record<string, string> = {};
try {
headers = getRequestHeaders() ?? {};
} catch {
headers = {};
}
return value.call(
target,
Effect.provideService(effect, CurrentHeaders, headers),
...rest,
);
};
}
return typeof value === "function" ? value.bind(target) : value;
},
});
};API surface
Query.fromRoute,Query.familyFromRouteQuery.serverFromRoute,Query.serverFamilyFromRouteQuery.swr,Query.lazyOptimisticMutation.fromRouteRequestHeaders.CurrentHeaders
