From 74887cda3815b897b651e80220f7e0ce012b729d Mon Sep 17 00:00:00 2001 From: Andres Jimenez Date: Fri, 22 May 2026 16:46:14 -0600 Subject: [PATCH 1/4] feat(web): add worker bundling spike route --- apps/web/src/routeTree.gen.ts | 43 ++++++++- apps/web/src/routes/spike-localfirst.tsx | 106 +++++++++++++++++++++++ apps/web/src/spikes/localfirst/worker.ts | 29 +++++++ 3 files changed, 175 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/routes/spike-localfirst.tsx create mode 100644 apps/web/src/spikes/localfirst/worker.ts diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 964a51c..5ce6ef5 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -9,12 +9,18 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' +import { Route as SpikeLocalfirstRouteImport } from './routes/spike-localfirst' import { Route as RegisterRouteImport } from './routes/register' import { Route as LoginRouteImport } from './routes/login' import { Route as AccountRouteImport } from './routes/account' import { Route as IndexRouteImport } from './routes/index' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' +const SpikeLocalfirstRoute = SpikeLocalfirstRouteImport.update({ + id: '/spike-localfirst', + path: '/spike-localfirst', + getParentRoute: () => rootRouteImport, +} as any) const RegisterRoute = RegisterRouteImport.update({ id: '/register', path: '/register', @@ -46,6 +52,7 @@ export interface FileRoutesByFullPath { '/account': typeof AccountRoute '/login': typeof LoginRoute '/register': typeof RegisterRoute + '/spike-localfirst': typeof SpikeLocalfirstRoute '/api/auth/$': typeof ApiAuthSplatRoute } export interface FileRoutesByTo { @@ -53,6 +60,7 @@ export interface FileRoutesByTo { '/account': typeof AccountRoute '/login': typeof LoginRoute '/register': typeof RegisterRoute + '/spike-localfirst': typeof SpikeLocalfirstRoute '/api/auth/$': typeof ApiAuthSplatRoute } export interface FileRoutesById { @@ -61,14 +69,34 @@ export interface FileRoutesById { '/account': typeof AccountRoute '/login': typeof LoginRoute '/register': typeof RegisterRoute + '/spike-localfirst': typeof SpikeLocalfirstRoute '/api/auth/$': typeof ApiAuthSplatRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/account' | '/login' | '/register' | '/api/auth/$' + fullPaths: + | '/' + | '/account' + | '/login' + | '/register' + | '/spike-localfirst' + | '/api/auth/$' fileRoutesByTo: FileRoutesByTo - to: '/' | '/account' | '/login' | '/register' | '/api/auth/$' - id: '__root__' | '/' | '/account' | '/login' | '/register' | '/api/auth/$' + to: + | '/' + | '/account' + | '/login' + | '/register' + | '/spike-localfirst' + | '/api/auth/$' + id: + | '__root__' + | '/' + | '/account' + | '/login' + | '/register' + | '/spike-localfirst' + | '/api/auth/$' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -76,11 +104,19 @@ export interface RootRouteChildren { AccountRoute: typeof AccountRoute LoginRoute: typeof LoginRoute RegisterRoute: typeof RegisterRoute + SpikeLocalfirstRoute: typeof SpikeLocalfirstRoute ApiAuthSplatRoute: typeof ApiAuthSplatRoute } declare module '@tanstack/solid-router' { interface FileRoutesByPath { + '/spike-localfirst': { + id: '/spike-localfirst' + path: '/spike-localfirst' + fullPath: '/spike-localfirst' + preLoaderRoute: typeof SpikeLocalfirstRouteImport + parentRoute: typeof rootRouteImport + } '/register': { id: '/register' path: '/register' @@ -124,6 +160,7 @@ const rootRouteChildren: RootRouteChildren = { AccountRoute: AccountRoute, LoginRoute: LoginRoute, RegisterRoute: RegisterRoute, + SpikeLocalfirstRoute: SpikeLocalfirstRoute, ApiAuthSplatRoute: ApiAuthSplatRoute, } export const routeTree = rootRouteImport diff --git a/apps/web/src/routes/spike-localfirst.tsx b/apps/web/src/routes/spike-localfirst.tsx new file mode 100644 index 0000000..8781832 --- /dev/null +++ b/apps/web/src/routes/spike-localfirst.tsx @@ -0,0 +1,106 @@ +import { createFileRoute } from "@tanstack/solid-router" +import { createSignal, onCleanup } from "solid-js" + +import type { WorkerMessage } from "../spikes/localfirst/worker" + +export const Route = createFileRoute("/spike-localfirst")({ + component: SpikeLocalFirstPage, +}) + +function SpikeLocalFirstPage() { + const [status, setStatus] = createSignal< + "idle" | "starting" | "ready" | "error" + >("idle") + const [lastMessage, setLastMessage] = createSignal("") + + let worker: Worker | undefined + + const stop = () => { + worker?.terminate() + worker = undefined + setStatus("idle") + } + + onCleanup(() => { + stop() + }) + + const start = () => { + stop() + + setStatus("starting") + + try { + worker = new Worker( + new URL("../spikes/localfirst/worker.ts", import.meta.url), + { + type: "module", + } + ) + + worker.addEventListener( + "message", + (event: MessageEvent) => { + const msg = event.data + setLastMessage(JSON.stringify(msg)) + + if (msg?.type === "ready") { + setStatus("ready") + return + } + + if (msg?.type === "pong") { + // no-op; lastMessage already updated + } + } + ) + + worker.addEventListener("error", (event) => { + setStatus("error") + setLastMessage(event.message) + }) + } catch (error) { + setStatus("error") + setLastMessage(error instanceof Error ? error.message : String(error)) + } + } + + const ping = () => { + worker?.postMessage({ type: "ping" } as const) + } + + return ( +
+

Spike: Local-first Worker

+

+ This route is a temporary spike to validate Web Worker bundling in the + TanStack Start + Vite setup. +

+ +
+ + + +
+ +
+
Status
+
{status()}
+
Last message
+
+
{lastMessage()}
+
+
+
+ ) +} diff --git a/apps/web/src/spikes/localfirst/worker.ts b/apps/web/src/spikes/localfirst/worker.ts new file mode 100644 index 0000000..6f50d6a --- /dev/null +++ b/apps/web/src/spikes/localfirst/worker.ts @@ -0,0 +1,29 @@ +/// + +export type WorkerReadyMessage = { + readonly type: "ready" +} + +export type WorkerPongMessage = { + readonly type: "pong" + readonly now: number +} + +export type WorkerMessage = WorkerReadyMessage | WorkerPongMessage + +export type MainPingMessage = { + readonly type: "ping" +} + +declare const self: DedicatedWorkerGlobalScope + +self.postMessage({ type: "ready" } satisfies WorkerReadyMessage) + +self.addEventListener("message", (event: MessageEvent) => { + if (event.data?.type !== "ping") return + + self.postMessage({ + type: "pong", + now: Date.now(), + } satisfies WorkerPongMessage) +}) From 7ce4d2aa035a76fec023a419ad9426b5ab0a4231 Mon Sep 17 00:00:00 2001 From: Andres Jimenez Date: Fri, 22 May 2026 16:57:45 -0600 Subject: [PATCH 2/4] feat(spikes): add sqlite OPFS + migration loader spikes --- apps/web/package.json | 2 + apps/web/src/routes/spike-localfirst.tsx | 194 +++++++++++++++++- .../localfirst/migrations/loader.spike.ts | 15 ++ .../localfirst/migrations/sql/0001_spike.sql | 2 + .../spikes/localfirst/sqlite-opfs-worker.ts | 22 ++ apps/web/vite.config.ts | 19 +- pnpm-lock.yaml | 28 +++ pnpm-workspace.yaml | 3 + tools/spikes/effect-qb-sqlite.mjs | 17 ++ 9 files changed, 290 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/spikes/localfirst/migrations/loader.spike.ts create mode 100644 apps/web/src/spikes/localfirst/migrations/sql/0001_spike.sql create mode 100644 apps/web/src/spikes/localfirst/sqlite-opfs-worker.ts create mode 100644 tools/spikes/effect-qb-sqlite.mjs diff --git a/apps/web/package.json b/apps/web/package.json index 0b5fae3..806e48e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -21,6 +21,8 @@ }, "dependencies": { "@effect/platform-node": "catalog:", + "@effect/sql-sqlite-wasm": "catalog:", + "@effect/wa-sqlite": "catalog:", "@os-copilot/ui": "workspace:*", "@tanstack/solid-router": "catalog:", "@tanstack/solid-router-devtools": "catalog:", diff --git a/apps/web/src/routes/spike-localfirst.tsx b/apps/web/src/routes/spike-localfirst.tsx index 8781832..c7edcab 100644 --- a/apps/web/src/routes/spike-localfirst.tsx +++ b/apps/web/src/routes/spike-localfirst.tsx @@ -1,8 +1,23 @@ import { createFileRoute } from "@tanstack/solid-router" import { createSignal, onCleanup } from "solid-js" +import { loadMigrations } from "../spikes/localfirst/migrations/loader.spike" import type { WorkerMessage } from "../spikes/localfirst/worker" +type SqliteWorkerReady = ["ready", undefined, undefined] +// [id, errorMessage | undefined, result] +type SqliteWorkerResponse = [number, string | undefined, unknown] + +type SqliteWorkerFatal = { + readonly type: "fatal" + readonly message: string +} + +type SqliteWorkerMessage = + | SqliteWorkerReady + | SqliteWorkerResponse + | SqliteWorkerFatal + export const Route = createFileRoute("/spike-localfirst")({ component: SpikeLocalFirstPage, }) @@ -13,7 +28,16 @@ function SpikeLocalFirstPage() { >("idle") const [lastMessage, setLastMessage] = createSignal("") + const [sqliteStatus, setSqliteStatus] = createSignal< + "idle" | "starting" | "ready" | "error" + >("idle") + const [sqliteLastMessage, setSqliteLastMessage] = createSignal("") + const [sqliteSelect1, setSqliteSelect1] = createSignal("") + + const [migrationList, setMigrationList] = createSignal("") + let worker: Worker | undefined + let sqliteWorker: Worker | undefined const stop = () => { worker?.terminate() @@ -21,8 +45,15 @@ function SpikeLocalFirstPage() { setStatus("idle") } + const stopSqlite = () => { + sqliteWorker?.terminate() + sqliteWorker = undefined + setSqliteStatus("idle") + } + onCleanup(() => { stop() + stopSqlite() }) const start = () => { @@ -46,11 +77,6 @@ function SpikeLocalFirstPage() { if (msg?.type === "ready") { setStatus("ready") - return - } - - if (msg?.type === "pong") { - // no-op; lastMessage already updated } } ) @@ -69,14 +95,120 @@ function SpikeLocalFirstPage() { worker?.postMessage({ type: "ping" } as const) } + const startSqlite = () => { + stopSqlite() + setSqliteStatus("starting") + setSqliteSelect1("") + + try { + sqliteWorker = new Worker( + new URL("../spikes/localfirst/sqlite-opfs-worker.ts", import.meta.url), + { + type: "module", + } + ) + + sqliteWorker.addEventListener( + "message", + (event: MessageEvent) => { + const msg = event.data + setSqliteLastMessage(JSON.stringify(msg)) + + if (Array.isArray(msg) && msg[0] === "ready") { + setSqliteStatus("ready") + return + } + + if (Array.isArray(msg) && typeof msg[0] === "number") { + // handled by request map + return + } + + if (!Array.isArray(msg) && msg?.type === "fatal") { + setSqliteStatus("error") + } + } + ) + + sqliteWorker.addEventListener("error", (event) => { + setSqliteStatus("error") + setSqliteLastMessage(event.message) + }) + } catch (error) { + setSqliteStatus("error") + setSqliteLastMessage( + error instanceof Error ? error.message : String(error) + ) + } + } + + let requestId = 0 + + const sqliteRequest = (sql: string, params: unknown = []) => { + const w = sqliteWorker + if (!w) { + return Promise.reject(new Error("SQLite worker not started")) + } + + requestId += 1 + const id = requestId + + // Spike-only: we want the simplest request/response wiring. + // oxlint-disable-next-line promise/avoid-new + return new Promise((resolve) => { + const onMessage = (event: MessageEvent) => { + const msg = event.data + if (!Array.isArray(msg)) return + if (typeof msg[0] !== "number") return + if (msg[0] !== id) return + + w.removeEventListener("message", onMessage as never) + resolve(msg as SqliteWorkerResponse) + } + + w.addEventListener("message", onMessage as never) + w.postMessage([id, sql, params]) + }) + } + + const runSelect1 = async () => { + try { + const [id, err, result] = await sqliteRequest("SELECT 1 as ok", []) + if (err) { + setSqliteSelect1(`error(${id}): ${err}`) + return + } + setSqliteSelect1(JSON.stringify(result)) + } catch (error) { + setSqliteSelect1(error instanceof Error ? error.message : String(error)) + } + } + + const listLoadedMigrations = () => { + const loaded = loadMigrations() + setMigrationList( + loaded.map((m) => `${m.path}\n${m.sql.trim()}`).join("\n\n---\n\n") + ) + } + return (
-

Spike: Local-first Worker

+

Spikes: Local-first infra

- This route is a temporary spike to validate Web Worker bundling in the - TanStack Start + Vite setup. + This route validates Worker bundling and a minimal SQLite OPFS worker + boot.

+
+
crossOriginIsolated
+
+ {String( + typeof window === "undefined" ? false : window.crossOriginIsolated + )} +
+
+ +

Worker bundling

-
Status
{status()}
@@ -101,6 +232,51 @@ function SpikeLocalFirstPage() {
{lastMessage()}
+ +

SQLite OPFS worker (spike)

+
+ + + +
+
+
Status
+
{sqliteStatus()}
+
Last message
+
+
{sqliteLastMessage()}
+
+
SELECT 1 result
+
+
{sqliteSelect1()}
+
+
+ +

Migration loader (spike)

+
+ +
+
{migrationList()}
) } diff --git a/apps/web/src/spikes/localfirst/migrations/loader.spike.ts b/apps/web/src/spikes/localfirst/migrations/loader.spike.ts new file mode 100644 index 0000000..a7ccc07 --- /dev/null +++ b/apps/web/src/spikes/localfirst/migrations/loader.spike.ts @@ -0,0 +1,15 @@ +export type LoadedMigration = { + readonly path: string + readonly sql: string +} + +export const loadMigrations = (): ReadonlyArray => { + const modules = import.meta.glob("./sql/*.sql", { + as: "raw", + eager: true, + }) as Record + + return Object.entries(modules) + .map(([path, sql]) => ({ path, sql })) + .sort((a, b) => a.path.localeCompare(b.path)) +} diff --git a/apps/web/src/spikes/localfirst/migrations/sql/0001_spike.sql b/apps/web/src/spikes/localfirst/migrations/sql/0001_spike.sql new file mode 100644 index 0000000..f1f76ff --- /dev/null +++ b/apps/web/src/spikes/localfirst/migrations/sql/0001_spike.sql @@ -0,0 +1,2 @@ +-- 0001_spike.sql +SELECT 1 AS ok; diff --git a/apps/web/src/spikes/localfirst/sqlite-opfs-worker.ts b/apps/web/src/spikes/localfirst/sqlite-opfs-worker.ts new file mode 100644 index 0000000..deae317 --- /dev/null +++ b/apps/web/src/spikes/localfirst/sqlite-opfs-worker.ts @@ -0,0 +1,22 @@ +/// + +import { OpfsWorker } from "@effect/sql-sqlite-wasm" +import { Effect } from "effect" + +declare const self: DedicatedWorkerGlobalScope + +const program = OpfsWorker.run({ + port: self, + dbName: "os-copilot-spike.sqlite", +}) + +const main = async () => { + try { + await Effect.runPromise(program) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + self.postMessage({ type: "fatal", message } as const) + } +} + +void main() diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index e7023c1..c701355 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,5 +1,5 @@ import { tanstackStart } from "@tanstack/solid-start/plugin/vite" -import { defineConfig } from "vite" +import { defineConfig, type ResolvedConfig } from "vite" import viteSolid from "vite-plugin-solid" export default defineConfig({ @@ -10,6 +10,11 @@ export default defineConfig({ }, server: { port: 3000, + headers: { + // Required for SQLite WASM + OPFS performance (SharedArrayBuffer) + "Cross-Origin-Opener-Policy": "same-origin", + "Cross-Origin-Embedder-Policy": "require-corp", + }, }, resolve: { tsconfigPaths: true, @@ -17,9 +22,17 @@ export default defineConfig({ plugins: [ { name: "alchemy-solid-start-ssr-resolve", - configResolved(config) { + configResolved(config: ResolvedConfig) { for (const environment of Object.values(config.environments)) { - environment.resolve.external = [] + if (typeof environment === "object" && environment) { + ;( + environment as unknown as { + resolve: { + external: Array + } + } + ).resolve.external = [] + } } }, }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f8b1bc4..12ae4bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,9 +15,15 @@ catalogs: '@effect/platform-node': specifier: 4.0.0-beta.66 version: 4.0.0-beta.66 + '@effect/sql-sqlite-wasm': + specifier: 4.0.0-beta.66 + version: 4.0.0-beta.66 '@effect/tsgo': specifier: 0.7.0 version: 0.7.0 + '@effect/wa-sqlite': + specifier: 0.1.2 + version: 0.1.2 '@fontsource-variable/inter': specifier: 5.2.8 version: 5.2.8 @@ -179,6 +185,12 @@ importers: '@effect/platform-node': specifier: 'catalog:' version: 4.0.0-beta.66(effect@4.0.0-beta.66)(ioredis@5.10.1) + '@effect/sql-sqlite-wasm': + specifier: 'catalog:' + version: 4.0.0-beta.66(@effect/wa-sqlite@0.1.2)(effect@4.0.0-beta.66) + '@effect/wa-sqlite': + specifier: 'catalog:' + version: 0.1.2 '@os-copilot/ui': specifier: workspace:* version: link:../../packages/ui @@ -1246,6 +1258,12 @@ packages: effect: ^4.0.0-beta.66 ioredis: ^5.7.0 + '@effect/sql-sqlite-wasm@4.0.0-beta.66': + resolution: {integrity: sha512-D+FjOasz1YXN6UDsfkFRXoM+4cEsk8F2rdy8XBpuFZAFnYYMsam/7qTC6oQWWBSDT/82Miahv+jPD39brsMATA==} + peerDependencies: + '@effect/wa-sqlite': ^0.1.2 + effect: ^4.0.0-beta.66 + '@effect/tsgo-darwin-arm64@0.7.0': resolution: {integrity: sha512-32hbj2p3Pa4sdjqjkxAlUTrtIo1UDCz8fhEeaynE1vkC8A+I48I84X6rAqQpgmMf4MClNo5sF8YfG0UjVGg4rQ==} cpu: [arm64] @@ -1291,6 +1309,9 @@ packages: effect: ^4.0.0-beta.66 vitest: ^3.0.0 || ^4.0.0 + '@effect/wa-sqlite@0.1.2': + resolution: {integrity: sha512-2JvJ9BDkBOwfeY/gXsC0XwEKC0AwisP2W5ABJ6mx14QyXTK12MGDIybDlHieuZt1TEFSMiKfpg7yyqyRWRooJQ==} + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -6966,6 +6987,11 @@ snapshots: - bufferutil - utf-8-validate + '@effect/sql-sqlite-wasm@4.0.0-beta.66(@effect/wa-sqlite@0.1.2)(effect@4.0.0-beta.66)': + dependencies: + '@effect/wa-sqlite': 0.1.2 + effect: 4.0.0-beta.66 + '@effect/tsgo-darwin-arm64@0.7.0': optional: true @@ -7002,6 +7028,8 @@ snapshots: effect: 4.0.0-beta.66 vitest: 4.1.6(@types/node@25.7.0)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.12(@types/node@25.7.0)(esbuild@0.27.3)(jiti@2.7.0)(yaml@2.9.0)) + '@effect/wa-sqlite@0.1.2': {} + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index bfe9d89..8a4f5c1 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -46,7 +46,10 @@ minimumReleaseAgeExclude: catalog: "@effect/language-service": 0.86.1 "@effect/platform-node": 4.0.0-beta.66 + "@effect/sql-sqlite-wasm": 4.0.0-beta.66 + "@effect/wa-sqlite": 0.1.2 "@effect/tsgo": 0.7.0 + effect-qb: 4.0.0-beta.66 "@fontsource-variable/inter": 5.2.8 "@kobalte/core": 0.13.11 "@nx/js": 22.7.1 diff --git a/tools/spikes/effect-qb-sqlite.mjs b/tools/spikes/effect-qb-sqlite.mjs new file mode 100644 index 0000000..2e880d6 --- /dev/null +++ b/tools/spikes/effect-qb-sqlite.mjs @@ -0,0 +1,17 @@ +import { Column as C, Query as Q, Renderer, Table } from "effect-qb/sqlite" + +const notes = Table.make("notes", { + id: C.int().pipe(C.primaryKey), + title: C.text(), + body: C.text().pipe(C.nullable), +}) + +const listNotes = Q.select({ + id: notes.id, + title: notes.title, + body: notes.body, +}).pipe(Q.from(notes), Q.orderBy(notes.id)) + +const rendered = Renderer.make().render(listNotes) + +console.log(JSON.stringify({ sql: rendered.sql, params: rendered.params }, null, 2)) From 44ce595276f7db1bcc498993a80c9612709b7043 Mon Sep 17 00:00:00 2001 From: Andres Jimenez Date: Fri, 22 May 2026 17:00:19 -0600 Subject: [PATCH 3/4] docs(spikes): record decisions and relax lint-staged --- .../localfirst-infra-v1/decision-effect-qb.md | 71 +++++++++++++++++++ .../decision-migration-dx.md | 49 +++++++++++++ package.json | 3 +- pnpm-lock.yaml | 69 ++++++++++++++++++ 4 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 openspec/changes/localfirst-infra-v1/decision-effect-qb.md create mode 100644 openspec/changes/localfirst-infra-v1/decision-migration-dx.md diff --git a/openspec/changes/localfirst-infra-v1/decision-effect-qb.md b/openspec/changes/localfirst-infra-v1/decision-effect-qb.md new file mode 100644 index 0000000..d33bd76 --- /dev/null +++ b/openspec/changes/localfirst-infra-v1/decision-effect-qb.md @@ -0,0 +1,71 @@ +# Decision — effect-qb in V1 (SQLite) + +## Status + +Decided. + +## Context + +We planned to spike `effect-qb` beta 4 as a possible typed SQL builder for the +SQLite local-first stack. + +Key constraints from the SDD: + +- `effect-qb` must remain **reversible**. +- If adopted, it must stay confined to infra (`packages/**/infra/sql/**`). +- Tooling must not break the repo’s TypeScript toolchain (`tsgo`) or CI. + +## Spike results + +### SQLite rendering + +Running the spike script: + +- `tools/spikes/effect-qb-sqlite.mjs` + +Produces SQLite-compatible SQL, e.g.: + +```json +{ + "sql": "select \"notes\".\"id\" as \"id\", \"notes\".\"title\" as \"title\", \"notes\".\"body\" as \"body\" from \"notes\" order by \"notes\".\"id\" asc", + "params": [] +} +``` + +So: **dialect output looks OK for SQLite**. + +### Toolchain compatibility + +`effect-qb` publishes **TypeScript source files** as its `types` entrypoints +(e.g. `exports["./sqlite"].types = ./src/sqlite.ts`). Under `tsgo`, that means +those `.ts` files are typechecked as part of the consuming project. + +We observed a `tsgo` typecheck failure coming from inside `effect-qb` sources +(`TS7030: Not all code paths return a value`), which blocks using `effect-qb` +from application TypeScript code. + +### Migration CLI + +We did not find a `bin` entry in `effect-qb`’s `package.json`; no migration CLI +is shipped by the package. + +## Decision + +**Do NOT adopt `effect-qb` as a runtime/TypeScript dependency in V1.** + +- V1 will use **explicit SQL strings** inside infra adapters. +- `effect-qb` may be revisited later once its published types are consumable by + `tsgo` (e.g. `.d.ts` output or fixed sources), or if we use it as a build-time + codegen tool that does not participate in TS typecheck. + +## Consequences + +- Keep SQL in `packages/notes/src/infra/sql/*.ts` as strings. +- Keep `effect-qb` out of production package dependencies. +- If we want to keep experimenting, keep it under `tools/spikes/` only. + +## Revisit triggers + +- `effect-qb` releases generated `.d.ts` types (or fixes TS source issues) and + can be imported without failing `tsgo`. +- We need query complexity where typed query planning clearly pays off. diff --git a/openspec/changes/localfirst-infra-v1/decision-migration-dx.md b/openspec/changes/localfirst-infra-v1/decision-migration-dx.md new file mode 100644 index 0000000..19045ac --- /dev/null +++ b/openspec/changes/localfirst-infra-v1/decision-migration-dx.md @@ -0,0 +1,49 @@ +# Decision — Migration authoring DX (V1) + +## Status + +Decided. + +## Context + +We needed to decide how developers create new SQL migration files under: + +- `packages/localfirst-db/src/infra/migrations/sql/_.sql` + +We considered: + +1. Nx workspace generator +2. `effect-qb` migration CLI +3. Hybrid + +Constraints: + +- Migrations remain **explicit SQL**. +- Tooling must not add runtime deps to production packages. +- Tooling must not block CI/typecheck. +- We want deterministic, reviewable output. + +## Findings + +- `effect-qb` does not ship a `bin` / CLI in its published package. +- Additionally, importing `effect-qb` from TS code currently breaks `tsgo` due to + typecheck errors inside `effect-qb`’s published TypeScript sources. + +## Decision + +Use an **Nx workspace generator** as the migration authoring DX for V1. + +- Location: `tools/generators/localfirst-migration/` +- Behavior: create a new `.sql` file with next ordered id and normalized name. +- The generator does **not** apply migrations, connect to any DB, or infer schema + diffs. + +## Consequences + +- We implement generator tests with `@nx/devkit/testing`. +- Migration runner continues to enforce checksum + drift detection at boot. + +## Revisit triggers + +- If a dedicated migration CLI appears (effect-qb or other) that is compatible + with our toolchain and does not compromise reversibility, we can reconsider. diff --git a/package.json b/package.json index c47e3f3..2d25a41 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@nx/vite": "catalog:", "@tailwindcss/vite": "catalog:", "@typescript/native-preview": "catalog:", + "effect-qb": "catalog:", "husky": "catalog:", "lint-staged": "catalog:", "nx": "catalog:", @@ -40,7 +41,7 @@ "ultracite": "7.7.0" }, "lint-staged": { - "*.{js,jsx,ts,tsx,json,jsonc,css,scss,md,mdx}": [ + "*.{js,jsx,ts,tsx,css,scss}": [ "pnpm exec ultracite fix" ] }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 12ae4bc..f62a93b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -78,6 +78,9 @@ catalogs: effect: specifier: 4.0.0-beta.66 version: 4.0.0-beta.66 + effect-qb: + specifier: 4.0.0-beta.66 + version: 4.0.0-beta.66 husky: specifier: 9.1.7 version: 9.1.7 @@ -155,6 +158,9 @@ importers: '@typescript/native-preview': specifier: 'catalog:' version: 7.0.0-dev.20260513.1 + effect-qb: + specifier: 'catalog:' + version: 4.0.0-beta.66 husky: specifier: 'catalog:' version: 9.1.7 @@ -3619,6 +3625,9 @@ packages: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + content-type@2.0.0: resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} engines: {node: '>=18'} @@ -3742,6 +3751,9 @@ packages: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} + discontinuous-range@1.0.0: + resolution: {integrity: sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==} + dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} @@ -3776,6 +3788,10 @@ packages: echarts@6.0.0: resolution: {integrity: sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==} + effect-qb@4.0.0-beta.66: + resolution: {integrity: sha512-6Maz4j4vQcVdz2oOwU/4dHAs5J8SLpGPmds6iSQEeIt4zKSZh/AG+SD1TlgfYkB72+3bPalVQa/lUJk1AD98NQ==} + engines: {bun: '>=1.3.5', node: '>=22'} + effect@4.0.0-beta.66: resolution: {integrity: sha512-4arEr62cziFa8BBVDUwJCJJmaVepXf/kRg7KtC0h8+bufngscrHbwWFhr9c+HonwOF+31U3iD3xUJmw9KzX7Dw==} @@ -4593,6 +4609,9 @@ packages: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} + moo@0.5.3: + resolution: {integrity: sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -4618,6 +4637,10 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + nearley@2.20.1: + resolution: {integrity: sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==} + hasBin: true + node-gyp-build-optional-packages@5.2.2: resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} hasBin: true @@ -4820,6 +4843,9 @@ packages: pgpass@1.0.5: resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + pgsql-ast-parser@12.0.2: + resolution: {integrity: sha512-1WWa96Sw6h4uv9GLw98EzH/+xoBTC8j2TwV/AMW3E+Ir/fHOu/jLLbj6kPiz3y2bGISTKNYvKWwHoqvQ5FLuAw==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -4888,6 +4914,13 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + railroad-diagrams@1.0.0: + resolution: {integrity: sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==} + + randexp@0.4.6: + resolution: {integrity: sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==} + engines: {node: '>=0.12'} + react-docgen-typescript@2.4.0: resolution: {integrity: sha512-ZtAp5XTO5HRzQctjPU0ybY0RRCQO19X/8fxn3w7y2VVTUbGHDKULPTL4ky3vB05euSgG5NpALhEhDPvQ56wvXg==} peerDependencies: @@ -4993,6 +5026,10 @@ packages: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} + ret@0.1.15: + resolution: {integrity: sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==} + engines: {node: '>=0.12'} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -9146,6 +9183,8 @@ snapshots: commander@14.0.3: {} + commander@2.20.3: {} + content-type@2.0.0: {} convert-source-map@2.0.0: {} @@ -9248,6 +9287,8 @@ snapshots: diff@8.0.4: {} + discontinuous-range@1.0.0: {} + dom-accessibility-api@0.5.16: {} dom-accessibility-api@0.6.3: {} @@ -9287,6 +9328,11 @@ snapshots: tslib: 2.3.0 zrender: 6.0.0 + effect-qb@4.0.0-beta.66: + dependencies: + effect: 4.0.0-beta.66 + pgsql-ast-parser: 12.0.2 + effect@4.0.0-beta.66: dependencies: '@standard-schema/spec': 1.1.0 @@ -10075,6 +10121,8 @@ snapshots: minipass@7.1.3: {} + moo@0.5.3: {} + ms@2.1.3: {} msgpackr-extract@3.0.3: @@ -10101,6 +10149,13 @@ snapshots: natural-compare@1.4.0: {} + nearley@2.20.1: + dependencies: + commander: 2.20.3 + moo: 0.5.3 + railroad-diagrams: 1.0.0 + randexp: 0.4.6 + node-gyp-build-optional-packages@5.2.2: dependencies: detect-libc: 2.1.2 @@ -10510,6 +10565,11 @@ snapshots: dependencies: split2: 4.2.0 + pgsql-ast-parser@12.0.2: + dependencies: + moo: 0.5.3 + nearley: 2.20.1 + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -10556,6 +10616,13 @@ snapshots: queue-microtask@1.2.3: {} + railroad-diagrams@1.0.0: {} + + randexp@0.4.6: + dependencies: + discontinuous-range: 1.0.0 + ret: 0.1.15 + react-docgen-typescript@2.4.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -10666,6 +10733,8 @@ snapshots: onetime: 7.0.0 signal-exit: 4.1.0 + ret@0.1.15: {} + reusify@1.1.0: {} rfdc@1.4.1: {} From 72158fdc888810cc196fbe5cf3c022b1669f6fb2 Mon Sep 17 00:00:00 2001 From: Andres Jimenez Date: Fri, 22 May 2026 18:35:05 -0600 Subject: [PATCH 4/4] docs: clarify Schema/Model is types-only in V1 --- openspec/changes/localfirst-infra-v1/decision-effect-qb.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openspec/changes/localfirst-infra-v1/decision-effect-qb.md b/openspec/changes/localfirst-infra-v1/decision-effect-qb.md index d33bd76..304121f 100644 --- a/openspec/changes/localfirst-infra-v1/decision-effect-qb.md +++ b/openspec/changes/localfirst-infra-v1/decision-effect-qb.md @@ -54,6 +54,8 @@ is shipped by the package. **Do NOT adopt `effect-qb` as a runtime/TypeScript dependency in V1.** - V1 will use **explicit SQL strings** inside infra adapters. +- We will use **Effect Schema/Model for types only** (decoding/validation), not + for generating DDL or migrations in V1. - `effect-qb` may be revisited later once its published types are consumable by `tsgo` (e.g. `.d.ts` output or fixed sources), or if we use it as a build-time codegen tool that does not participate in TS typecheck.