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/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..c7edcab --- /dev/null +++ b/apps/web/src/routes/spike-localfirst.tsx @@ -0,0 +1,282 @@ +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, +}) + +function SpikeLocalFirstPage() { + const [status, setStatus] = createSignal< + "idle" | "starting" | "ready" | "error" + >("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() + worker = undefined + setStatus("idle") + } + + const stopSqlite = () => { + sqliteWorker?.terminate() + sqliteWorker = undefined + setSqliteStatus("idle") + } + + onCleanup(() => { + stop() + stopSqlite() + }) + + 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") + } + } + ) + + 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) + } + + 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 ( +
+

Spikes: Local-first infra

+

+ This route validates Worker bundling and a minimal SQLite OPFS worker + boot. +

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

Worker bundling

+
+ + + +
+
+
Status
+
{status()}
+
Last message
+
+
{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/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) +}) 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/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..304121f --- /dev/null +++ b/openspec/changes/localfirst-infra-v1/decision-effect-qb.md @@ -0,0 +1,73 @@ +# 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. +- 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. + +## 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 f8b1bc4..f62a93b 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 @@ -72,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 @@ -149,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 @@ -179,6 +191,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 +1264,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 +1315,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==} @@ -3598,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'} @@ -3721,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==} @@ -3755,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==} @@ -4572,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==} @@ -4597,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 @@ -4799,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==} @@ -4867,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: @@ -4972,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'} @@ -6966,6 +7024,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 +7065,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 @@ -9118,6 +9183,8 @@ snapshots: commander@14.0.3: {} + commander@2.20.3: {} + content-type@2.0.0: {} convert-source-map@2.0.0: {} @@ -9220,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: {} @@ -9259,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 @@ -10047,6 +10121,8 @@ snapshots: minipass@7.1.3: {} + moo@0.5.3: {} + ms@2.1.3: {} msgpackr-extract@3.0.3: @@ -10073,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 @@ -10482,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: {} @@ -10528,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 @@ -10638,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: {} 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))