Skip to content
Open
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
2 changes: 2 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:",
Expand Down
43 changes: 40 additions & 3 deletions apps/web/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -46,13 +52,15 @@ export interface FileRoutesByFullPath {
'/account': typeof AccountRoute
'/login': typeof LoginRoute
'/register': typeof RegisterRoute
'/spike-localfirst': typeof SpikeLocalfirstRoute
'/api/auth/$': typeof ApiAuthSplatRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/account': typeof AccountRoute
'/login': typeof LoginRoute
'/register': typeof RegisterRoute
'/spike-localfirst': typeof SpikeLocalfirstRoute
'/api/auth/$': typeof ApiAuthSplatRoute
}
export interface FileRoutesById {
Expand All @@ -61,26 +69,54 @@ 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 {
IndexRoute: typeof IndexRoute
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'
Expand Down Expand Up @@ -124,6 +160,7 @@ const rootRouteChildren: RootRouteChildren = {
AccountRoute: AccountRoute,
LoginRoute: LoginRoute,
RegisterRoute: RegisterRoute,
SpikeLocalfirstRoute: SpikeLocalfirstRoute,
ApiAuthSplatRoute: ApiAuthSplatRoute,
}
export const routeTree = rootRouteImport
Expand Down
282 changes: 282 additions & 0 deletions apps/web/src/routes/spike-localfirst.tsx
Original file line number Diff line number Diff line change
@@ -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<string>("")

const [sqliteStatus, setSqliteStatus] = createSignal<
"idle" | "starting" | "ready" | "error"
>("idle")
const [sqliteLastMessage, setSqliteLastMessage] = createSignal<string>("")
const [sqliteSelect1, setSqliteSelect1] = createSignal<string>("")

const [migrationList, setMigrationList] = createSignal<string>("")

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<WorkerMessage>) => {
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<SqliteWorkerMessage>) => {
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<SqliteWorkerResponse>((resolve) => {
const onMessage = (event: MessageEvent<SqliteWorkerMessage>) => {
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 (
<section>
<h1>Spikes: Local-first infra</h1>
<p>
This route validates Worker bundling and a minimal SQLite OPFS worker
boot.
</p>

<dl>
<dt>crossOriginIsolated</dt>
<dd>
{String(
typeof window === "undefined" ? false : window.crossOriginIsolated
)}
</dd>
</dl>

<h2>Worker bundling</h2>
<div style={{ display: "flex", gap: "0.5rem" }}>
<button
type="button"
onClick={start}
disabled={status() === "starting"}
>
Start worker
</button>
<button type="button" onClick={ping} disabled={status() !== "ready"}>
Ping
</button>
<button type="button" onClick={stop} disabled={status() === "idle"}>
Stop
</button>
</div>
<dl>
<dt>Status</dt>
<dd>{status()}</dd>
<dt>Last message</dt>
<dd>
<pre>{lastMessage()}</pre>
</dd>
</dl>

<h2>SQLite OPFS worker (spike)</h2>
<div style={{ display: "flex", gap: "0.5rem" }}>
<button
type="button"
onClick={startSqlite}
disabled={sqliteStatus() === "starting"}
>
Start sqlite worker
</button>
<button
type="button"
onClick={runSelect1}
disabled={sqliteStatus() !== "ready"}
>
SELECT 1
</button>
<button
type="button"
onClick={stopSqlite}
disabled={sqliteStatus() === "idle"}
>
Stop sqlite worker
</button>
</div>
<dl>
<dt>Status</dt>
<dd>{sqliteStatus()}</dd>
<dt>Last message</dt>
<dd>
<pre>{sqliteLastMessage()}</pre>
</dd>
<dt>SELECT 1 result</dt>
<dd>
<pre>{sqliteSelect1()}</pre>
</dd>
</dl>

<h2>Migration loader (spike)</h2>
<div style={{ display: "flex", gap: "0.5rem" }}>
<button type="button" onClick={listLoadedMigrations}>
Load migrations
</button>
</div>
<pre>{migrationList()}</pre>
</section>
)
}
Loading
Loading