diff --git a/.github/workflows/pr-preview-cleanup.yml b/.github/workflows/pr-preview-cleanup.yml index 9c19c8f..6c6e6b0 100644 --- a/.github/workflows/pr-preview-cleanup.yml +++ b/.github/workflows/pr-preview-cleanup.yml @@ -1,7 +1,10 @@ name: PR Cleanup on: - pull_request: + # Run from the base branch so closing a PR targeting dev is cleaned up too. + # This workflow only destroys an explicitly derived pr-N stage and does not + # execute code from the closed PR. + pull_request_target: types: [closed] workflow_dispatch: inputs: @@ -16,7 +19,7 @@ permissions: env: AWS_REGION: eu-west-2 - STAGE_NAME: ${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || format('pr-{0}', github.event.inputs.pr_number) }} + STAGE_NAME: ${{ github.event_name == 'pull_request_target' && format('pr-{0}', github.event.pull_request.number) || format('pr-{0}', github.event.inputs.pr_number) }} jobs: # =========================================== @@ -58,22 +61,31 @@ jobs: # credentials, and provider errors should fail the job directly. run: | set +e - LOG_FILE="$RUNNER_TEMP/sst-remove.log" - npx sst remove --stage "${{ env.STAGE_NAME }}" --print-logs 2>&1 | tee "$LOG_FILE" - EXIT_CODE=${PIPESTATUS[0]} + MAX_ATTEMPTS=3 + for attempt in $(seq 1 "$MAX_ATTEMPTS"); do + LOG_FILE="$RUNNER_TEMP/sst-remove-${attempt}.log" + npx sst remove --stage "${{ env.STAGE_NAME }}" --print-logs 2>&1 | tee "$LOG_FILE" + EXIT_CODE=${PIPESTATUS[0]} - if [ "$EXIT_CODE" -eq 0 ]; then - exit 0 - fi + if [ "$EXIT_CODE" -eq 0 ]; then + exit 0 + fi - if grep -Eiq 'lock|already[[:space:]]+running|another[[:space:]].*process|acquir(e|ing).*state' "$LOG_FILE"; then - echo "lock_failure=true" >> "$GITHUB_OUTPUT" - echo "SST removal failed because the stage appears to be locked; retrying after unlock." - exit 0 - fi + if grep -Eiq 'lock|already[[:space:]]+running|another[[:space:]].*process|acquir(e|ing).*state' "$LOG_FILE"; then + echo "lock_failure=true" >> "$GITHUB_OUTPUT" + echo "SST removal failed because the stage appears to be locked; retrying after unlock." + exit 0 + fi - echo "SST removal failed for a non-lock error; not retrying." - exit "$EXIT_CODE" + if grep -Eiq 'bad status: 5[0-9]{2}|gateway timeout|could not install bun|timed out|econnreset' "$LOG_FILE" && [ "$attempt" -lt "$MAX_ATTEMPTS" ]; then + echo "Transient SST bootstrap/network failure; retrying in 10 seconds (attempt $((attempt + 1))/$MAX_ATTEMPTS)." + sleep 10 + continue + fi + + echo "SST removal failed for a non-retryable error." + exit "$EXIT_CODE" + done - name: Clear stale SST lock if: steps.destroy.outputs.lock_failure == 'true' diff --git a/docs/core/development/development_tools.md b/docs/core/development/development_tools.md index c609b75..a1099ed 100644 --- a/docs/core/development/development_tools.md +++ b/docs/core/development/development_tools.md @@ -190,27 +190,15 @@ npx sst dev # Default multiplexed mode with tabbed UI (Linux/mac - Use `--mode=mono` to avoid log file accumulation (logs go to stdout only) - If enabling `--print-logs` or `--verbose`, monitor disk usage: `du -sh .sst/` -### Caddy HTTP/2 Proxy (Local Development) +### Local Dev Server (port 3000) -`sst dev` auto-starts Caddy as an HTTP/2 reverse proxy via an `sst.x.DevCommand`. -Electric SQL's long-polling shapes benefit from HTTP/2 multiplexing; Vite's dev -server only supports HTTP/1.1 (6 concurrent connections per domain). +`sst dev` serves the app directly from Vite on `http://localhost:3000` — +no reverse proxy required. (An earlier Caddy HTTP/2 proxy existed for +Electric SQL's long-polling shapes; it was removed with the Electric +decommission.) -**One-time setup:** - -```bash -brew install caddy -sudo security add-trusted-cert -d -r trustRoot \ - -k /Library/Keychains/System.keychain \ - ~/.local/share/caddy/pki/authorities/local/root.crt -``` - -**Usage:** access the app at `https://localhost:3010/app` (not port 3000). -All agent-browser commands and tests should use port 3010. - -If Caddy isn't installed, the DevCommand pane shows a helpful error but doesn't -crash the rest of `sst dev`. Port 3000 still works directly, but Electric shapes -will be slower due to the HTTP/1.1 connection limit. +**Usage:** access the app at `http://localhost:3000/app`. All agent-browser +commands and tests should use port 3000. --- diff --git a/infra/dev.ts b/infra/dev.ts deleted file mode 100644 index bf5a777..0000000 --- a/infra/dev.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Local development helpers. - * - * Caddy provides an HTTP/2 reverse proxy for Electric SQL's concurrent - * long-polling streams, avoiding Vite's HTTP/1.1 connection limit (6 - * concurrent connections per domain). Access the app at - * https://localhost:3010 instead of http://localhost:3000. - * - * Requires Caddy to be installed: `brew install caddy && caddy trust` - */ -export const caddy = new sst.x.DevCommand("Caddy", { - dev: { - autostart: true, - command: "bash scripts/start-caddy.sh", - }, -}); diff --git a/infra/electric-cloud.ts b/infra/electric-cloud.ts deleted file mode 100644 index 6296df1..0000000 --- a/infra/electric-cloud.ts +++ /dev/null @@ -1,655 +0,0 @@ -import * as pulumi from "@pulumi/pulumi"; - -/** - * Electric Cloud management API base URL. - * The dashboard API uses a JSON-RPC style protocol via @orpc/client. - * Do NOT use api.electric-sql.cloud/v1 — that is the shape proxy, not the - * management API. - */ -const ELECTRIC_CLOUD_API = "https://dashboard.electric-sql.cloud/api/rpc"; - -/** - * Region for Electric Cloud Postgres Sync services. - * Must be eu-west-1 (eu-west-2 is not supported by Electric Cloud). - */ -const ELECTRIC_REGION = "eu-west-1"; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -interface AuthHeaders { - Authorization: string; - "Content-Type": string; -} - -function auth(token: string): AuthHeaders { - return { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }; -} - -/** - * Custom error to distinguish "resource not found" responses (which arrive - * as HTTP 200 with {"json":{"defined":true,"code":"NOT_FOUND",…}}) from - * true HTTP / network failures. - */ -class ObjectNotFoundError extends Error { - constructor(message: string) { - super(message); - this.name = "ObjectNotFoundError"; - } -} - -/** - * Call an Electric Cloud RPC endpoint. - * - * The @orpc/client protocol wraps params in {"json": {…}} and unwraps - * responses from {"json": {…}}. Errors also arrive as HTTP 200 with a - * NOT_FOUND / etc. code inside the json envelope. - */ -async function rpcCall>( - method: string, - headers: AuthHeaders, - params: Record, -): Promise { - const url = `${ELECTRIC_CLOUD_API}/${method}`; - const res = await fetch(url, { - method: "POST", - headers, - body: JSON.stringify({ json: params }), - }); - - if (!res.ok) { - const text = await res.text(); - throw new Error( - `Electric Cloud API POST ${method} failed (${res.status}): ${text}`, - ); - } - - const body = (await res.json()) as { - json?: T & { defined?: boolean; code?: string; status?: number; message?: string }; - }; - - // The API returns HTTP 200 even for errors; check the json envelope. - if (body.json && "code" in body.json && body.json.code === "NOT_FOUND") { - throw new ObjectNotFoundError( - body.json.message ?? "Resource not found", - ); - } - - return body.json as T; -} - -// --------------------------------------------------------------------------- -// Inputs & outputs -// --------------------------------------------------------------------------- - -export interface ElectricCloudSyncInputs { - /** Full connection URI of the Neon branch to sync from. */ - databaseUrl?: string; - /** Neon API key for fetching database credentials. */ - neonApiKey?: string; - /** Neon project ID for fetching database credentials. */ - neonProjectId?: string; - /** - * Endpoint host from the preview branch's Neon endpoint. - * Used together with neonApiKey + neonProjectId to construct a valid - * database URL inside the provider. - */ - endpointHost?: string; - /** Electric Cloud API token. */ - apiToken: string; - /** Electric Cloud project ID. */ - projectId?: string; - /** Deprecated alias for projectId. */ - electricProjectId?: string; - /** SST stage name (used to name the environment). */ - stage: string; -} - -export interface ElectricCloudSyncOutputs { - /** The Electric service ID (used as the source_id in shape requests). */ - sourceId: string; - /** The Electric service secret (used as the secret in shape requests). */ - secret: string; - /** The Electric service ID (same as sourceId). */ - serviceId: string; - /** The Electric environment ID. */ - environmentId: string; - /** The Electric environment name. */ - environmentName: string; - /** - * API token stored in outputs so it is available during delete/read. - * The Pulumi state is encrypted, so this is safe to persist. - */ - apiToken: string; - /** Project ID stored in outputs for delete/read lifecycle methods. */ - projectId: string; -} - -// --------------------------------------------------------------------------- -// Provider -// --------------------------------------------------------------------------- - -class ElectricCloudSyncProvider - implements pulumi.dynamic.ResourceProvider -{ - /** - * Wait for a Postgres Sync service to reach the "active" status. - * Polls every 5 seconds up to a maximum of 60 seconds. - */ - private async waitForActive( - serviceId: string, - environmentId: string, - token: string, - timeoutMs = 60_000, - ): Promise { - const headers = auth(token); - const deadline = Date.now() + timeoutMs; - - while (Date.now() < deadline) { - const result = await rpcCall<{ - services: Array<{ id: string; status: string }>; - }>("services/list", headers, { environmentId }); - - const svc = result.services.find((s) => s.id === serviceId); - if (!svc) { - throw new Error( - `Electric Cloud Postgres service ${serviceId} not found in environment ${environmentId}`, - ); - } - if (svc.status === "active") return; - if (svc.status === "failed") { - throw new Error( - `Electric Cloud Postgres service ${serviceId} entered failed status`, - ); - } - await new Promise((r) => setTimeout(r, 5000)); - } - - throw new Error( - `Electric Cloud Postgres service ${serviceId} did not become active within ${timeoutMs}ms`, - ); - } - - /** Best-effort service delete (swallows errors for cleanup paths). */ - private async deleteServiceSafe( - serviceId: string, - headers: AuthHeaders, - ): Promise { - try { - await rpcCall("services/delete", headers, { - serviceId, - forceDelete: true, - }); - } catch { - // best effort - } - } - - /** Best-effort environment delete (swallows errors for cleanup paths). */ - private async deleteEnvironmentSafe( - environmentId: string, - headers: AuthHeaders, - ): Promise { - try { - await rpcCall("environments/delete", headers, { - environmentId, - }); - } catch { - // best effort - } - } - - /** Clean up both service and environment after a mid-creation failure. */ - private async cleanup( - serviceId: string, - environmentId: string, - headers: AuthHeaders, - ): Promise { - await this.deleteServiceSafe(serviceId, headers); - await this.deleteEnvironmentSafe(environmentId, headers); - } - - /** - * Look up an environment by name in the project, or return null. - */ - private async findEnvironmentByName( - projectId: string, - name: string, - headers: AuthHeaders, - ): Promise { - try { - const result = await rpcCall<{ - environments: Array<{ id: string; name: string }>; - }>("environments/list", headers, { projectId }); - const match = result.environments.find((e) => e.name === name); - return match ? match.id : null; - } catch { - return null; - } - } - - // -- helpers ---------------------------------------------------------- - - /** - * Build a database URL from Neon API data + the preview endpoint host. - * Called when the pre-constructed databaseUrl contains "undefined". - * - * Fetches the dev project's connection URI via the Neon API (branches → - * databases → connection_uri) and re-writes the host to `endpointHost`. - * The dev credentials are valid on every branch of the project; only the - * compute endpoint differs per stage. - */ - private async buildDatabaseUrl( - apiKey: string, - neonProjectId: string, - endpointHost: string, - ): Promise { - if (!apiKey || !neonProjectId || !endpointHost) { - throw new Error( - `Cannot build database URL: missing credentials ` + - `(apiKey=${!!apiKey}, neonProjectId=${!!neonProjectId}, endpointHost=${!!endpointHost})`, - ); - } - - const headers = { - Authorization: `Bearer ${apiKey}`, - "Content-Type": "application/json", - } as const; - - // 1. Find the default branch - const NEON_API = "https://console.neon.tech/api/v2"; - const branchesRes = await fetch( - `${NEON_API}/projects/${neonProjectId}/branches`, - { headers }, - ); - if (!branchesRes.ok) { - throw new Error( - `Neon API: failed to list branches: ${branchesRes.status} ${await branchesRes.text()}`, - ); - } - const branches = (await branchesRes.json()) as { - branches: Array<{ id: string; default?: boolean; name?: string }>; - }; - const defaultBranch = branches.branches.find((b) => b.default); - if (!defaultBranch) { - throw new Error( - `Neon API: no default branch found in project ${neonProjectId}`, - ); - } - - // 2. Get the database name and owner from the default branch - const dbsRes = await fetch( - `${NEON_API}/projects/${neonProjectId}/branches/${defaultBranch.id}/databases`, - { headers }, - ); - if (!dbsRes.ok) { - throw new Error( - `Neon API: failed to list databases: ${dbsRes.status} ${await dbsRes.text()}`, - ); - } - const dbs = (await dbsRes.json()) as { - databases: Array<{ name: string; owner_name: string }>; - }; - const db = dbs.databases[0]; - if (!db) { - throw new Error( - `Neon API: no databases found on branch ${defaultBranch.id}`, - ); - } - - // 3. Get the connection URI for this database/role (for the password) - const connRes = await fetch( - `${NEON_API}/projects/${neonProjectId}/connection_uri` + - `?database_name=${encodeURIComponent(db.name)}` + - `&role_name=${encodeURIComponent(db.owner_name)}`, - { headers }, - ); - if (!connRes.ok) { - throw new Error( - `Neon API: failed to get connection URI: ${connRes.status} ${await connRes.text()}`, - ); - } - const conn = (await connRes.json()) as { uri: string }; - const originalUri = new URL(conn.uri); - - // 4. Build the final URL — use the password and user from the dev - // project's connection URI, but the host from the preview endpoint. - return `postgresql://${db.owner_name}:${encodeURIComponent(originalUri.password)}@${endpointHost}:5432/${db.name}`; - } - - // -- lifecycle --------------------------------------------------------- - - async create( - inputs: ElectricCloudSyncInputs, - ): Promise { - const { - apiToken, - projectId, - stage, - databaseUrl, - neonApiKey, - neonProjectId, - endpointHost, - } = inputs; - const envName = `preview-${stage}`; - const headers = auth(apiToken); - - // Resolve the database URL. If the pre-built URL contains "undefined" - // (due to dynamic-resource output timing), construct a valid one - // from the resolved endpointHost + Neon API credentials. - const resolvedUrl = databaseUrl && !databaseUrl.includes("undefined") - ? databaseUrl - : await this.buildDatabaseUrl( - neonApiKey ?? "", - neonProjectId ?? "", - endpointHost ?? "", - ); - - // 1a. Check if the environment already exists (idempotent create) - let existingId = await this.findEnvironmentByName( - projectId, - envName, - headers, - ); - if (existingId) { - console.log( - `Electric Cloud: re-using existing environment '${envName}' (${existingId})`, - ); - } - - // 1b. Create an environment in the Electric Cloud project (or fail - // gracefully if the name is reserved but not visible — try a - // slightly different name with a suffix). - let environmentId: string; - if (existingId) { - environmentId = existingId; - } else { - try { - const env = await rpcCall<{ environmentId: string }>( - "projects/createEnvironment", - headers, - { projectId, name: envName }, - ); - environmentId = env.environmentId; - } catch (err) { - // If the name is reserved but not visible in the listing, - // create with a unique suffix. - const msg = - err instanceof Error ? err.message : String(err); - if (msg.includes("ENVIRONMENT_NAME_ALREADY_EXISTS")) { - const fallbackName = `${envName}-${Date.now().toString(36)}`; - console.log( - `Electric Cloud: name '${envName}' reserved; trying '${fallbackName}'`, - ); - const env = await rpcCall<{ environmentId: string }>( - "projects/createEnvironment", - headers, - { - projectId, - name: fallbackName, - }, - ); - environmentId = env.environmentId; - } else { - throw new Error( - `Failed to create Electric Cloud environment '${envName}': ${msg}`, - ); - } - } - } - - // 2. Create a Postgres Sync service connected to the Neon branch - let serviceId: string; - let sourceSecret: string; - try { - const svc = await rpcCall<{ id: string; sourceSecret: string }>( - "services/postgres/create", - headers, - { - environmentId, - databaseUrl: resolvedUrl, - region: ELECTRIC_REGION, - scaling: { maxConnections: 5 }, - poolSize: 5, - }, - ); - serviceId = svc.id; - sourceSecret = svc.sourceSecret; - } catch (err) { - await this.deleteEnvironmentSafe(environmentId, headers); - throw new Error( - `Failed to create Electric Cloud Postgres service: ${err instanceof Error ? err.message : String(err)}`, - ); - } - - // 3. Wait for the service to become active - try { - await this.waitForActive(serviceId, environmentId, apiToken); - } catch (err) { - await this.cleanup(serviceId, environmentId, headers); - throw err; - } - - // 4. Retrieve the service secret - let serviceSecret: string; - try { - const secretResult = await rpcCall<{ secret: string }>( - "services/getSecret", - headers, - { serviceId }, - ); - serviceSecret = secretResult.secret; - } catch (err) { - // Fall back to the sourceSecret returned during creation - serviceSecret = sourceSecret; - } - - return { - id: serviceId, - outs: { - sourceId: serviceId, - secret: serviceSecret, - serviceId, - environmentId, - environmentName: envName, - // Persist credentials in encrypted state for delete/read - apiToken, - projectId, - }, - }; - } - - async read( - id: string, - props: ElectricCloudSyncInputs & Partial, - ): Promise { - const environmentId = props.environmentId ?? ""; - const token = props.apiToken ?? ""; - const headers = auth(token); - - // Without an environmentId we cannot list services. - if (!environmentId) { - return emptyResult(id); - } - - try { - // List services in the environment and find ours by ID. - const result = await rpcCall<{ - services: Array<{ id: string; status: string }>; - }>("services/list", headers, { environmentId }); - - const svc = result.services.find((s) => s.id === id); - if (!svc) { - return emptyResult(id); - } - - // Try to fetch the secret; keep stored value on failure. - let secret = props.secret ?? ""; - try { - const secretResult = await rpcCall<{ secret: string }>( - "services/getSecret", - headers, - { serviceId: id }, - ); - secret = secretResult.secret; - } catch { - // keep whatever was stored - } - - return { - id, - outs: { - sourceId: id, - secret, - serviceId: id, - environmentId, - environmentName: props.stage - ? `preview-${props.stage}` - : "", - apiToken: props.apiToken ?? "", - projectId: props.projectId ?? "", - }, - }; - } catch (err) { - // Service or environment gone — return empty outs so Pulumi - // knows to recreate the resource. - if (err instanceof ObjectNotFoundError) { - return emptyResult(id); - } - throw err; - } - } - - async delete( - id: string, - props: ElectricCloudSyncOutputs, - ): Promise { - const token = props.apiToken ?? ""; - const headers = auth(token); - - // Delete the service first, then the environment. - // This order is important: the environment cannot be removed while - // it still contains services. - const errors: string[] = []; - - if (props.serviceId) { - try { - await rpcCall("services/delete", headers, { - serviceId: props.serviceId, - forceDelete: true, - }); - } catch (err) { - if (!(err instanceof ObjectNotFoundError)) { - errors.push( - `service: ${err instanceof Error ? err.message : String(err)}`, - ); - } - } - } - - if (props.environmentId) { - try { - await rpcCall("environments/delete", headers, { - environmentId: props.environmentId, - }); - } catch (err) { - if (!(err instanceof ObjectNotFoundError)) { - errors.push( - `environment: ${err instanceof Error ? err.message : String(err)}`, - ); - } - } - } - - if (errors.length > 0) { - throw new Error( - `Electric Cloud cleanup encountered errors: ${errors.join("; ")}`, - ); - } - } -} - -/** Convenience: an empty ReadResult (resource no longer exists). */ -function emptyResult(id: string): pulumi.dynamic.ReadResult { - return { - id, - outs: { - sourceId: "", - secret: "", - serviceId: "", - environmentId: "", - environmentName: "", - apiToken: "", - projectId: "", - }, - }; -} - -// --------------------------------------------------------------------------- -// Resource -// --------------------------------------------------------------------------- - -/** - * A Pulumi dynamic resource that manages an Electric Cloud Postgres Sync - * service and its parent environment. - * - * Inputs: - * databaseUrl – Full connection URI of the Neon branch - * apiToken – Electric Cloud API token - * projectId – Electric Cloud project ID - * stage – SST stage name - * - * Outputs: - * sourceId – Service ID (used as source_id for shape requests) - * secret – Service secret (used as auth for shape requests) - * serviceId – Same as sourceId - * environmentId – Electric environment ID - * environmentName – Electric environment name (preview-{stage}) - */ -export class ElectricCloudSync extends pulumi.dynamic.Resource { - public readonly sourceId!: pulumi.Output; - public readonly secret!: pulumi.Output; - public readonly serviceId!: pulumi.Output; - public readonly environmentId!: pulumi.Output; - public readonly environmentName!: pulumi.Output; - - constructor( - name: string, - args: { - databaseUrl?: pulumi.Input; - neonApiKey?: pulumi.Input; - neonProjectId?: pulumi.Input; - endpointHost?: pulumi.Input; - apiToken: pulumi.Input; - projectId: pulumi.Input; - stage: pulumi.Input; - }, - opts?: pulumi.CustomResourceOptions, - ) { - super( - new ElectricCloudSyncProvider(), - name, - { - databaseUrl: args.databaseUrl ?? "", - neonApiKey: args.neonApiKey ?? "", - neonProjectId: args.neonProjectId ?? "", - endpointHost: args.endpointHost ?? "", - apiToken: args.apiToken, - projectId: args.projectId, - stage: args.stage, - sourceId: undefined, - secret: undefined, - serviceId: undefined, - environmentId: undefined, - environmentName: undefined, - // apiToken and projectId are stored as outputs so they're - // available in the delete lifecycle method - }, - opts, - ); - } -} diff --git a/infra/secret.ts b/infra/secret.ts index 084da45..b274a8b 100644 --- a/infra/secret.ts +++ b/infra/secret.ts @@ -6,10 +6,6 @@ export const secret = { GoogleClientId: new sst.Secret('GoogleOAuthClientId'), GoogleClientSecret: new sst.Secret('GoogleOAuthClientSecret'), LoopsApiKey: new sst.Secret('LoopsApiKey'), - ElectricSqlSource: new sst.Secret('ElectricSqlSource'), - ElectricSqlSecret: new sst.Secret('ElectricSqlSecret'), - ElectricCloudApiToken: new sst.Secret('ElectricCloudApiToken'), - ElectricCloudProjectId: new sst.Secret('ElectricCloudProjectId'), PosthogPublicKey: new sst.Secret('PosthogPublicKey'), PosthogHost: new sst.Secret('PosthogHost'), StripeSecretKey: new sst.Secret('StripeSecretKey'), diff --git a/infra/sync.ts b/infra/sync.ts deleted file mode 100644 index 1893d11..0000000 --- a/infra/sync.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { IS_DEPLOYED_STAGE } from './dns'; -import { secret } from './secret'; -import { ElectricCloudSync } from './electric-cloud'; -import { Database } from './database'; - -// Compute the SyncEngine properties based on the stage. -// For permanent stages (dev, production): use existing static secrets. -// For preview/personal stages: provision dynamic Electric Cloud resources. -const { syncSource, syncSecret } = (() => { - if (IS_DEPLOYED_STAGE) { - return { - syncSource: secret.ElectricSqlSource.value, - syncSecret: secret.ElectricSqlSecret.value, - }; - } - - const sync = new ElectricCloudSync('ElectricCloudSync', { - // Pass the endpoint host (resolves correctly) and Neon credentials, - // so the provider can construct a valid database URL by fetching - // user / password / database name from the Neon API directly. - // This bypasses dynamic-resource output timing issues (the URL - // constructed from devProject outputs would contain "undefined"). - endpointHost: Database.properties.url.apply((url) => { - try { - return new URL(url).hostname; - } catch { - return ""; - } - }), - neonApiKey: secret.NeonApiKey.value, - neonProjectId: secret.NeonProjectId.value, - apiToken: secret.ElectricCloudApiToken.value, - projectId: secret.ElectricCloudProjectId.value, - stage: $app.stage, - }, { - dependsOn: [Database], - }); - - return { - syncSource: sync.sourceId, - syncSecret: sync.secret, - }; -})(); - -export const SyncEngine = new sst.Linkable('SyncEngine', { - properties: { - source: syncSource, - secret: syncSecret, - }, -}); diff --git a/infra/web.ts b/infra/web.ts index 553632e..d4a497c 100644 --- a/infra/web.ts +++ b/infra/web.ts @@ -6,7 +6,6 @@ import { authEmail, notifyEmail } from './email'; import { bucket, optimisedBucket, bucketRegion, optimisedBucketRegion } from './storage'; import { cdn } from './cloudfront'; import { secret } from './secret'; -import { SyncEngine } from './sync'; export const app = new sst.aws.TanStackStart('Web', { path: 'packages/web', @@ -34,7 +33,6 @@ export const app = new sst.aws.TanStackStart('Web', { bucket, optimisedBucket, cdn, - SyncEngine, dataService, secret.GoogleClientId, secret.GoogleClientSecret, @@ -48,11 +46,6 @@ export const app = new sst.aws.TanStackStart('Web', { secret.ExpoOwner, secret.BetterAuthSecret, secret.LoopsApiKey, - // Used at runtime to resolve the Electric Cloud sync source id + - // secret on preview stages (the ElectricCloudSync dynamic resource's - // outputs cannot be baked into the SyncEngine link). - secret.ElectricCloudApiToken, - secret.ElectricCloudProjectId, secret.DataServiceToken, ], environment: { diff --git a/package-lock.json b/package-lock.json index a116d57..b24f178 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2269,21 +2269,6 @@ "@noble/ciphers": "^1.0.0" } }, - "node_modules/@electric-sql/client": { - "version": "1.5.27", - "resolved": "https://registry.npmjs.org/@electric-sql/client/-/client-1.5.27.tgz", - "integrity": "sha512-JXyhdg2CBvTRGK1aEGBkb24HoN4Zk9v5lA9Ibxjmcrgjt1j/NgnXndxmuSxVtAYZsM4no5kHXfTnFplgiI9ztg==", - "license": "Apache-2.0", - "dependencies": { - "@microsoft/fetch-event-source": "^2.0.1" - }, - "bin": { - "intent": "bin/intent.mjs" - }, - "optionalDependencies": { - "@rollup/rollup-darwin-arm64": "^4.18.1" - } - }, "node_modules/@electric-sql/pglite": { "version": "0.3.15", "license": "Apache-2.0", @@ -2996,10 +2981,6 @@ "node": ">=10.3.0" } }, - "node_modules/@microsoft/fetch-event-source": { - "version": "2.0.1", - "license": "MIT" - }, "node_modules/@mixmark-io/domino": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz", @@ -8359,29 +8340,6 @@ "solid-js": ">=1.9.7" } }, - "node_modules/@tanstack/electric-db-collection": { - "version": "0.4.7", - "resolved": "https://registry.npmjs.org/@tanstack/electric-db-collection/-/electric-db-collection-0.4.7.tgz", - "integrity": "sha512-FXmHGe0ZsU/pp1UsEU7HMVd9hm9oQWzxnLamZ2b4av1DIxyIJY51Oof2xtSlBa2V/alEHASSS10gYmXN3ENbdA==", - "license": "MIT", - "dependencies": { - "@electric-sql/client": "^1.5.15", - "@standard-schema/spec": "^1.1.0", - "@tanstack/db": "0.8.7", - "@tanstack/store": "^0.9.2", - "debug": "^4.4.3" - } - }, - "node_modules/@tanstack/electric-db-collection/node_modules/@tanstack/store": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.9.3.tgz", - "integrity": "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, "node_modules/@tanstack/history": { "version": "1.161.4", "license": "MIT", @@ -22418,7 +22376,6 @@ "@assistant-ui/react-markdown": "^0.14.8", "@content-collections/core": "^0.13.1", "@content-collections/vite": "^0.2.8", - "@electric-sql/client": "^1.5.12", "@hookform/resolvers": "^5.2.2", "@hugeicons/core-free-icons": "^4.2.3", "@hugeicons/react": "^1.1.9", @@ -22435,7 +22392,6 @@ "@radix-ui/react-toast": "^1.2.15", "@radix-ui/react-tooltip": "^1.2.8", "@tailwindcss/typography": "^0.5.19", - "@tanstack/electric-db-collection": "^0.4.7", "@tanstack/query-db-collection": "^1.2.12", "@tanstack/react-db": "^0.3.7", "@tanstack/react-query": "^5.90.20", diff --git a/packages/backend/src/chat/clerk-runtime.ts b/packages/backend/src/chat/clerk-runtime.ts index 1c29519..857276b 100644 --- a/packages/backend/src/chat/clerk-runtime.ts +++ b/packages/backend/src/chat/clerk-runtime.ts @@ -2,7 +2,6 @@ import { type ChatModel, chatMessage, chatRun, - chatRunEvent, } from "@structa/core/conversation"; import { db } from "@structa/core/drizzle"; import { chat } from "@tanstack/ai"; @@ -77,44 +76,7 @@ export const clerkRuntime: ClerkRuntime = { status: "running", }); - let sequence = 0; let responseText = ""; - const appendEvent = async ( - type: string, - payload: Record, - ) => { - sequence += 1; - await db.insert(chatRunEvent).values({ - runId: input.runId, - sessionId: input.conversationId, - userId: input.userId, - seq: sequence, - type, - payload, - }); - }; - - // Batch content tokens to reduce DB writes and Electric sync overhead. - // Electric's long-polling has multi-second replication latency; writing - // one event per token would flood the WAL and starve the client of - // updates. Accumulating for 500ms gives the user visible progress - // while keeping the write count manageable. - let batchBuffer = ""; - let batchTimer: ReturnType | null = null; - const flushBatch = async () => { - if (batchTimer) { - clearTimeout(batchTimer); - batchTimer = null; - } - if (batchBuffer) { - const chunk = batchBuffer; - batchBuffer = ""; - await appendEvent("content", { text: chunk }); - } - }; - const scheduleFlush = () => { - if (!batchTimer) batchTimer = setTimeout(() => void flushBatch(), 500); - }; try { const stream = chat({ @@ -136,28 +98,9 @@ export const clerkRuntime: ClerkRuntime = { for await (const chunk of stream) { if (chunk.type === "TEXT_MESSAGE_CONTENT") { responseText += chunk.delta; - batchBuffer += chunk.delta; - scheduleFlush(); - } else if ( - chunk.type === "TOOL_CALL_START" || - chunk.type === "TOOL_CALL_END" - ) { - await flushBatch(); - await appendEvent( - "tool_call", - chunk as unknown as Record, - ); - } else if (chunk.type === "TOOL_CALL_RESULT") { - await flushBatch(); - await appendEvent( - "tool_result", - chunk as unknown as Record, - ); } } - await flushBatch(); - await appendEvent("done", {}); if (responseText) { await db.insert(chatMessage).values({ id: `${input.runId}:assistant`, @@ -173,10 +116,8 @@ export const clerkRuntime: ClerkRuntime = { .set({ status: "complete", updatedAt: new Date() }) .where(eq(chatRun.id, input.runId)); } catch (error) { - await flushBatch(); const message = error instanceof Error ? error.message : "Clerk run failed"; - await appendEvent("error", { message }); await db .update(chatRun) .set({ status: "error", errorMessage: message, updatedAt: new Date() }) diff --git a/packages/core/migrations/0003_drop_chat_run_events.sql b/packages/core/migrations/0003_drop_chat_run_events.sql new file mode 100644 index 0000000..2275c49 --- /dev/null +++ b/packages/core/migrations/0003_drop_chat_run_events.sql @@ -0,0 +1 @@ +DROP TABLE "chat_run_events"; diff --git a/packages/core/migrations/meta/_journal.json b/packages/core/migrations/meta/_journal.json index 5977b30..6388fb3 100644 --- a/packages/core/migrations/meta/_journal.json +++ b/packages/core/migrations/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1787042297000, "tag": "0002_chat_runs", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1789923469000, + "tag": "0003_drop_chat_run_events", + "breakpoints": true } ] } diff --git a/packages/core/src/auth/auth.sql.ts b/packages/core/src/auth/auth.sql.ts index 0797b95..ec589ba 100644 --- a/packages/core/src/auth/auth.sql.ts +++ b/packages/core/src/auth/auth.sql.ts @@ -102,6 +102,6 @@ export const accountRelations = relations(account, ({ one }) => ({ }), })); -// Zod schemas for Electric sync and tRPC validation +// Zod schemas for tRPC validation and TanStack Query Collections export const selectUserSchema = createSelectSchema(user); export const updateUserSchema = createUpdateSchema(user); diff --git a/packages/core/src/conversation/chat.sql.ts b/packages/core/src/conversation/chat.sql.ts index 2466eee..2e7199d 100644 --- a/packages/core/src/conversation/chat.sql.ts +++ b/packages/core/src/conversation/chat.sql.ts @@ -1,10 +1,7 @@ import { index, - integer, - jsonb, pgEnum, pgTable, - primaryKey, text, timestamp, } from "drizzle-orm/pg-core"; @@ -61,25 +58,3 @@ export const chatRun = pgTable( index("chat_runs_session_status_idx").on(table.sessionId, table.status), ], ); - -export const chatRunEvent = pgTable( - "chat_run_events", - { - runId: text("run_id") - .notNull() - .references(() => chatRun.id, { onDelete: "cascade" }), - sessionId: text("session_id") - .notNull() - .references(() => conversation.id, { onDelete: "cascade" }), - userId: text("user_id"), - seq: integer("seq").notNull(), - type: text("type").notNull(), - payload: jsonb("payload").$type>().notNull(), - createdAt: timestamp("created_at").defaultNow().notNull(), - }, - (table) => [ - primaryKey({ columns: [table.runId, table.seq] }), - index("chat_run_events_session_seq_idx").on(table.sessionId, table.seq), - index("chat_run_events_user_idx").on(table.userId), - ], -); diff --git a/packages/functions/src/cloudflare/durable-objects/conversation-object.ts b/packages/functions/src/cloudflare/durable-objects/conversation-object.ts index ee3f952..1a91b8b 100644 --- a/packages/functions/src/cloudflare/durable-objects/conversation-object.ts +++ b/packages/functions/src/cloudflare/durable-objects/conversation-object.ts @@ -284,6 +284,18 @@ export class ConversationObject extends DurableObject { createdAt: now, }, }); + this.broadcast({ + type: "run", + run: { + id: run.runId, + sessionId: conversationId, + userId: run.userId, + status: "running", + errorMessage: null, + createdAt: now, + updatedAt: now, + }, + }); this.ctx.waitUntil(this.executeRun(run, conversationId)); } diff --git a/packages/scripts/src/libs.ts b/packages/scripts/src/libs.ts index 57f7766..c356a1a 100644 --- a/packages/scripts/src/libs.ts +++ b/packages/scripts/src/libs.ts @@ -12,8 +12,6 @@ export const registry: DocPage[] = [ { lib: 'tanstack-router', name: 'overview', url: 'https://tanstack.com/router/latest/docs/overview', filename: 'overview.md' }, { lib: 'tanstack-start', name: 'overview', url: 'https://tanstack.com/start/latest/docs/overview', filename: 'overview.md' }, { lib: 'tanstack-db', name: 'overview', url: 'https://tanstack.com/db/latest/docs/overview', filename: 'overview.md' }, - // ElectricSQL - { lib: 'electricsql', name: 'overview', url: 'https://electric-sql.com/docs', filename: 'overview.md' }, // SST { lib: 'sst', name: 'overview', url: 'https://docs.sst.dev/', filename: 'overview.md' }, // Drizzle diff --git a/packages/web/package.json b/packages/web/package.json index b12211f..f7619d3 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -14,7 +14,6 @@ "@assistant-ui/react-markdown": "^0.14.8", "@content-collections/core": "^0.13.1", "@content-collections/vite": "^0.2.8", - "@electric-sql/client": "^1.5.12", "@hookform/resolvers": "^5.2.2", "@hugeicons/core-free-icons": "^4.2.3", "@hugeicons/react": "^1.1.9", @@ -31,7 +30,6 @@ "@radix-ui/react-toast": "^1.2.15", "@radix-ui/react-tooltip": "^1.2.8", "@tailwindcss/typography": "^0.5.19", - "@tanstack/electric-db-collection": "^0.4.7", "@tanstack/query-db-collection": "^1.2.12", "@tanstack/react-db": "^0.3.7", "@tanstack/react-query": "^5.90.20", diff --git a/packages/web/src/hooks/use-update-user.ts b/packages/web/src/hooks/use-update-user.ts index 5bf98f4..b6f14a9 100644 --- a/packages/web/src/hooks/use-update-user.ts +++ b/packages/web/src/hooks/use-update-user.ts @@ -12,15 +12,15 @@ interface UpdateUserInput { } /** - * Hook to update user settings via Electric collection + * Hook to update user settings via the users Query Collection * * Flow: * 1. If image provided, upload to S3 first via presigned URL - * 2. Update via Electric collection (applies optimistic update) - * 3. Collection's onUpdate handler persists via tRPC mutation - * 4. Wait for the mutation to be confirmed via Electric sync (txid match). - * Rejects — rolling back the optimistic update — if the server update - * fails or the txid never syncs back. + * 2. Update via the collection (applies an optimistic update) + * 3. Collection's onUpdate handler persists via a tRPC mutation + * 4. Wait for the mutation to be confirmed (server round-trip reflected in + * the collection). Rejects — rolling back the optimistic update — if the + * server update fails or the change never comes back from the server. * * @param userId - The ID of the user to update */ @@ -54,7 +54,7 @@ export function useUpdateUser(userId: string) { } } - // Update via Electric collection - applies an optimistic update and + // Update via the collection - applies an optimistic update and // triggers the collection's onUpdate handler (tRPC mutation) const transaction = usersCollection.update(values.userId, (draft) => { draft.name = values.name; @@ -64,8 +64,8 @@ export function useUpdateUser(userId: string) { } }); - // Wait until the mutation is persisted and confirmed via Electric - // sync (txid match) so the caller can react to the real outcome + // Wait until the mutation is persisted (server round-trip confirmed) + // so the caller can react to the real outcome await transaction.isPersisted.promise; return { success: true }; @@ -74,11 +74,13 @@ export function useUpdateUser(userId: string) { // Toasts are reserved for errors and warnings. onError: (error) => { console.error("Error updating user settings:", error); - toast.error( - error instanceof Error - ? error.message - : "Failed to update settings. Please try again.", - ); + const message = + typeof error === "object" && + error !== null && + "message" in error + ? String(error.message) + : "Failed to update settings. Please try again."; + toast.error(message); }, }); } diff --git a/packages/web/src/hooks/use-user.ts b/packages/web/src/hooks/use-user.ts index 11c8569..d4601eb 100644 --- a/packages/web/src/hooks/use-user.ts +++ b/packages/web/src/hooks/use-user.ts @@ -4,8 +4,9 @@ import { useLiveQuery } from "@tanstack/react-db"; import { usersCollection } from "@/lib/collections"; /** - * Hook to get the current user from the Electric collection with real-time sync. - * Uses findOne() to get a single user (Electric shape already filters to current user). + * Hook to get the current user from the Query Collection (server-synced). + * Uses findOne() to get a single user (the collection only holds the + * authenticated user's row). * * Returns an object with user data and loading state to match TanStack DB patterns. * @@ -16,7 +17,7 @@ import { usersCollection } from "@/lib/collections"; * * if (isLoading) return ; * - * // Use user data (may be undefined if sync failed) + * // Use user data (may be undefined if the fetch failed) * const name = user?.name ?? authUser.name; */ export function useUser() { diff --git a/packages/web/src/lib/__tests__/buffer-polyfill.test.ts b/packages/web/src/lib/__tests__/buffer-polyfill.test.ts new file mode 100644 index 0000000..606c8b3 --- /dev/null +++ b/packages/web/src/lib/__tests__/buffer-polyfill.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { installBufferPolyfill } from "../buffer-polyfill"; + +describe("installBufferPolyfill", () => { + it("installs a callable Buffer constructor for instanceof checks", () => { + const target: { Buffer?: unknown } = {}; + + installBufferPolyfill(target); + + const BufferConstructor = target.Buffer as typeof Uint8Array & { + from(data: string): Uint8Array; + }; + const value = BufferConstructor.from("hello"); + + expect(typeof BufferConstructor).toBe("function"); + expect(value).toBeInstanceOf(BufferConstructor); + }); +}); diff --git a/packages/web/src/lib/__tests__/chat-production.test.ts b/packages/web/src/lib/__tests__/chat-production.test.ts index b4f5810..a4b11bd 100644 --- a/packages/web/src/lib/__tests__/chat-production.test.ts +++ b/packages/web/src/lib/__tests__/chat-production.test.ts @@ -4,7 +4,8 @@ import { convertProductionMessage, foldProductionMessages, } from "../chat-production/fold"; -import type { ChatMessage, ChatRun, ChatRunEvent } from "../collections"; +import type { ChatMessage, ChatRun } from "../collections"; +import type { ChatRunEvent } from "../chat-production/types"; const date = new Date("2026-08-18T10:00:00.000Z"); const message: ChatMessage = { @@ -96,6 +97,39 @@ describe("production chat fold", () => { { type: "text", text: "The typical cost is £8,000–£15,000." }, ]); }); + + it("renders content events while a run is still running", () => { + const runningRun = { ...run, status: "running" as const }; + const folded = foldProductionMessages( + [message], + [runningRun], + [event("content", 1, { content: "Streaming now" })], + ); + + const assistant = convertProductionMessage(folded[1]); + + expect(assistant.content).toEqual([ + { type: "text", text: "Streaming now" }, + ]); + expect(assistant.status).toEqual({ type: "running" }); + }); + + it("keeps the user message before its run despite timestamp skew", () => { + const skewedUserMessage = { + ...message, + runId: run.id, + createdAt: new Date(date.getTime() + 10_000), + }; + const runningRun = { ...run, status: "running" as const }; + const folded = foldProductionMessages( + [skewedUserMessage], + [runningRun], + [event("content", 1, { content: "Streaming now" })], + ); + + expect(folded[0]?.kind).toBe("user"); + expect(folded[1]?.kind).toBe("assistant"); + }); }); describe("production chat adapter", () => { @@ -120,7 +154,7 @@ describe("production chat adapter", () => { expect(onSessionChange).toHaveBeenCalledWith(expect.any(String)); }); - it("navigates before waiting for the first run to finish", async () => { + it("navigates after the first run is accepted", async () => { let resolveSend!: () => void; const send = vi.fn( () => new Promise((resolve) => (resolveSend = resolve)), @@ -139,10 +173,11 @@ describe("production chat adapter", () => { ); const pending = adapter.onNew({ content: "hello" } as never); - expect(onSessionChange).toHaveBeenCalledWith(expect.any(String)); + expect(onSessionChange).not.toHaveBeenCalled(); expect(send).toHaveBeenCalledOnce(); resolveSend(); await pending; + expect(onSessionChange).toHaveBeenCalledWith(expect.any(String)); }); }); diff --git a/packages/web/src/lib/__tests__/electric-proxy.test.ts b/packages/web/src/lib/__tests__/electric-proxy.test.ts deleted file mode 100644 index 72facc0..0000000 --- a/packages/web/src/lib/__tests__/electric-proxy.test.ts +++ /dev/null @@ -1,235 +0,0 @@ -/** - * Tests for Electric Proxy Utility - */ - -import { Resource } from "sst"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -// Mock SST Resource. The object is mutable so tests can exercise both the -// static-link path (dev/production) and the runtime-resolution path -// (preview stages, where SyncEngine has no baked properties). -vi.mock("sst", () => ({ - Resource: { - App: { name: "structa", stage: "pr-99" }, - SyncEngine: { - type: "sst.sst.Linkable", - source: "svc-test-source-id", - secret: "test-electric-secret-12345", - }, - ElectricCloudApiToken: { value: "test-electric-token-12345" }, - ElectricCloudProjectId: { value: "proj-test-project-12345" }, - }, -})); - -type MockResource = { - App: { stage: string }; - SyncEngine?: { type: string; source?: string; secret?: string }; - ElectricCloudApiToken: { value: string }; - ElectricCloudProjectId: { value: string }; -}; -const mockResource = Resource as unknown as MockResource; - -// Import after mocking -import { - buildElectricUpstreamUrl, - getElectricSecret, - getElectricSourceId, - proxyToElectric, - resetElectricCredentialsCache, -} from "../electric-proxy"; - -describe("Electric Proxy Utility", () => { - beforeEach(() => { - resetElectricCredentialsCache(); - // Restore the static-link shape for tests that rely on it. - mockResource.SyncEngine = { - type: "sst.sst.Linkable", - source: "svc-test-source-id", - secret: "test-electric-secret-12345", - }; - }); - - describe("getElectricSourceId", () => { - it("returns the Electric source ID", async () => { - const sourceId = await getElectricSourceId(); - expect(sourceId).toBe("svc-test-source-id"); - }); - }); - - describe("getElectricSecret", () => { - it("returns the Electric secret", async () => { - const secret = await getElectricSecret(); - expect(secret).toBe("test-electric-secret-12345"); - }); - }); - - describe("buildElectricUpstreamUrl", () => { - it("builds URL with required Electric parameters", async () => { - const request = new Request("http://localhost:3000/api/users"); - const url = await buildElectricUpstreamUrl(request, "user"); - - expect(url.origin).toBe("https://api.electric-sql.cloud"); - expect(url.pathname).toBe("/v1/shape"); - expect(url.searchParams.get("table")).toBe("user"); - expect(url.searchParams.get("secret")).toBe("test-electric-secret-12345"); - expect(url.searchParams.get("source_id")).toBe("svc-test-source-id"); - }); - - it("includes where clause when provided", async () => { - const request = new Request("http://localhost:3000/api/users"); - const url = await buildElectricUpstreamUrl( - request, - "user", - "id = 'user-123'", - ); - - expect(url.searchParams.get("where")).toBe("id = 'user-123'"); - }); - - it("passes through Electric protocol parameters", async () => { - const request = new Request( - "http://localhost:3000/api/users?offset=100&handle=abc123&live=true", - ); - const url = await buildElectricUpstreamUrl(request, "user"); - - expect(url.searchParams.get("offset")).toBe("100"); - expect(url.searchParams.get("handle")).toBe("abc123"); - expect(url.searchParams.get("live")).toBe("true"); - }); - - it("ignores non-Electric protocol parameters", async () => { - const request = new Request( - "http://localhost:3000/api/users?foo=bar&custom=value", - ); - const url = await buildElectricUpstreamUrl(request, "user"); - - expect(url.searchParams.get("foo")).toBeNull(); - expect(url.searchParams.get("custom")).toBeNull(); - }); - }); - - describe("runtime resolution (preview stages)", () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("resolves source + secret from the Electric Cloud API when the link is empty", async () => { - // Preview stage: SyncEngine exists but carries no baked properties. - mockResource.SyncEngine = { type: "sst.sst.Linkable" }; - - vi.spyOn(global, "fetch").mockImplementation((input) => { - const url = input instanceof Request ? input.url : String(input); - if (url.endsWith("/environments/list")) { - return Promise.resolve( - new Response( - JSON.stringify({ - json: { - environments: [ - { id: "env-preview-1", name: "preview-pr-99" }, - ], - }, - }), - ), - ); - } - if (url.endsWith("/services/list")) { - return Promise.resolve( - new Response( - JSON.stringify({ - json: { - services: [ - { id: "svc-runtime-source", name: "postgres-sync" }, - ], - }, - }), - ), - ); - } - if (url.endsWith("/services/getSecret")) { - return Promise.resolve( - new Response( - JSON.stringify({ json: { secret: "runtime-secret-67890" } }), - ), - ); - } - throw new Error(`Unexpected fetch: ${url}`); - }); - - expect(await getElectricSourceId()).toBe("svc-runtime-source"); - expect(await getElectricSecret()).toBe("runtime-secret-67890"); - }); - - it("throws when the stage environment does not exist", async () => { - mockResource.SyncEngine = { type: "sst.sst.Linkable" }; - - vi.spyOn(global, "fetch").mockImplementation(() => - Promise.resolve( - new Response(JSON.stringify({ json: { environments: [] } })), - ), - ); - - await expect(getElectricSourceId()).rejects.toThrow( - "Electric Cloud environment 'preview-pr-99' not found", - ); - }); - }); - - describe("proxyToElectric", () => { - beforeEach(() => { - vi.spyOn(global, "fetch").mockImplementation( - () => - Promise.resolve( - new Response(JSON.stringify({ data: "test" }), { - status: 200, - statusText: "OK", - headers: { - "content-type": "application/json", - "content-encoding": "gzip", - "content-length": "123", - }, - }), - ) as Promise, - ); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("fetches from the upstream URL", async () => { - const url = new URL("https://api.electric-sql.cloud/v1/shape?table=user"); - await proxyToElectric(url); - - expect(global.fetch).toHaveBeenCalledWith(url); - }); - - it("removes content-encoding header", async () => { - const url = new URL("https://api.electric-sql.cloud/v1/shape?table=user"); - const response = await proxyToElectric(url); - - expect(response.headers.get("content-encoding")).toBeNull(); - }); - - it("removes content-length header", async () => { - const url = new URL("https://api.electric-sql.cloud/v1/shape?table=user"); - const response = await proxyToElectric(url); - - expect(response.headers.get("content-length")).toBeNull(); - }); - - it("adds Vary header for cookie-based auth", async () => { - const url = new URL("https://api.electric-sql.cloud/v1/shape?table=user"); - const response = await proxyToElectric(url); - - expect(response.headers.get("Vary")).toBe("Cookie"); - }); - - it("preserves response status and body", async () => { - const url = new URL("https://api.electric-sql.cloud/v1/shape?table=user"); - const response = await proxyToElectric(url); - - expect(response.status).toBe(200); - expect(response.statusText).toBe("OK"); - }); - }); -}); diff --git a/packages/web/src/lib/__tests__/row-dates.test.ts b/packages/web/src/lib/__tests__/row-dates.test.ts new file mode 100644 index 0000000..0529b50 --- /dev/null +++ b/packages/web/src/lib/__tests__/row-dates.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { normalizeRowDates } from "../row-dates"; + +describe("normalizeRowDates", () => { + it("normalizes nullable and required JSON timestamps", () => { + const row = normalizeRowDates({ + createdAt: "2026-09-20T12:00:00.000Z", + updatedAt: "2026-09-20T12:01:00.000Z", + lastMessageAt: null, + }); + + expect(row).toMatchObject({ + createdAt: expect.any(Date), + updatedAt: expect.any(Date), + lastMessageAt: null, + }); + }); +}); diff --git a/packages/web/src/lib/__tests__/user-row.test.ts b/packages/web/src/lib/__tests__/user-row.test.ts new file mode 100644 index 0000000..47706e9 --- /dev/null +++ b/packages/web/src/lib/__tests__/user-row.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { normalizeUserRow } from "../user-row"; + +describe("parseUserRow", () => { + it("normalizes JSON date strings from tRPC", () => { + const user = normalizeUserRow({ + id: "user-123", + name: "Test User", + email: "test@example.com", + emailVerified: true, + image: null, + createdAt: "2026-09-18T12:00:00.000Z", + updatedAt: "2026-09-18T12:00:00.000Z", + workspaceId: null, + workspaceName: null, + role: null, + plan: "pro", + product: null, + }); + + expect(user).toMatchObject({ + createdAt: expect.any(Date), + updatedAt: expect.any(Date), + }); + }); +}); diff --git a/packages/web/src/lib/buffer-polyfill.ts b/packages/web/src/lib/buffer-polyfill.ts index 2ae3cc8..70b86d9 100644 --- a/packages/web/src/lib/buffer-polyfill.ts +++ b/packages/web/src/lib/buffer-polyfill.ts @@ -1,53 +1,63 @@ -// Polyfill for Buffer in browser environment (required by gray-matter) -if (typeof window !== 'undefined') { - (window as any).Buffer = { - from: function(data: string | Uint8Array, encoding?: string): Uint8Array { - if (typeof data === 'string') { - if (encoding === 'hex') { - const hexString = data.replace(/\s/g, ''); - const bytes = new Uint8Array(hexString.length / 2); - for (let i = 0; i < hexString.length; i += 2) { - bytes[i / 2] = parseInt(hexString.substr(i, 2), 16); - } - return bytes; - } - if (encoding === 'base64') { - const binaryString = atob(data); - const bytes = new Uint8Array(binaryString.length); - for (let i = 0; i < binaryString.length; i++) { - bytes[i] = binaryString.charCodeAt(i); - } - return bytes; - } - const encoder = new TextEncoder(); - return encoder.encode(data); - } - return new Uint8Array(data); - }, - isBuffer: function(obj: unknown): boolean { - return obj instanceof Uint8Array; - }, - byteLength: function(data: string | Uint8Array): number { - if (typeof data === 'string') { - return new Blob([data]).size; - } - return data.length; - }, - concat: function(list: Uint8Array[]): Uint8Array { - const totalLength = list.reduce((acc, buf) => acc + buf.length, 0); - const result = new Uint8Array(totalLength); - let offset = 0; - for (const buf of list) { - result.set(buf, offset); - offset += buf.length; - } - return result; - }, - alloc: function(size: number): Uint8Array { - return new Uint8Array(size); - }, - allocUnsafe: function(size: number): Uint8Array { - return new Uint8Array(size); - }, - } as any; +type BufferTarget = { + Buffer?: unknown; +}; + +/** Install the smallest callable Buffer-compatible surface used by the web app. */ +export function installBufferPolyfill(target: BufferTarget) { + if (typeof target.Buffer === "function") { + return; + } + + class BrowserBuffer extends Uint8Array {} + + const from = (data: string | Uint8Array, encoding?: string) => { + if (typeof data !== "string") { + return new BrowserBuffer(data); + } + + if (encoding === "hex") { + const hexString = data.replace(/\s/g, ""); + const bytes = new BrowserBuffer(hexString.length / 2); + for (let i = 0; i < hexString.length; i += 2) { + bytes[i / 2] = Number.parseInt(hexString.slice(i, i + 2), 16); + } + return bytes; + } + + if (encoding === "base64") { + const binaryString = atob(data); + const bytes = new BrowserBuffer(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes; + } + + return new BrowserBuffer(new TextEncoder().encode(data)); + }; + + Object.assign(BrowserBuffer, { + from, + isBuffer: (value: unknown) => value instanceof Uint8Array, + byteLength: (data: string | Uint8Array) => + typeof data === "string" ? new Blob([data]).size : data.length, + concat: (list: Uint8Array[]) => { + const totalLength = list.reduce((total, value) => total + value.length, 0); + const result = new BrowserBuffer(totalLength); + let offset = 0; + for (const value of list) { + result.set(value, offset); + offset += value.length; + } + return result; + }, + alloc: (size: number) => new BrowserBuffer(size), + allocUnsafe: (size: number) => new BrowserBuffer(size), + }); + + target.Buffer = BrowserBuffer; +} + +if (typeof window !== "undefined") { + installBufferPolyfill(window); } diff --git a/packages/web/src/lib/chat-production/adapter.ts b/packages/web/src/lib/chat-production/adapter.ts index b2f8510..d6b6161 100644 --- a/packages/web/src/lib/chat-production/adapter.ts +++ b/packages/web/src/lib/chat-production/adapter.ts @@ -3,7 +3,8 @@ import type { ExternalStoreAdapter, ThreadSuggestion, } from "@assistant-ui/react"; -import type { ChatMessage, ChatRun, ChatRunEvent } from "@/lib/collections"; +import type { ChatMessage, ChatRun } from "@/lib/collections"; +import type { ChatRunEvent } from "./types"; import { convertProductionMessage, foldProductionMessages, @@ -16,6 +17,8 @@ export type ProductionAdapterInput = { messages: ChatMessage[]; runs: ChatRun[]; events: ChatRunEvent[]; + userId?: string; + onOptimisticMessage?: (message: ChatMessage) => void; onSessionChange: (sessionId: string | null) => void; projectId?: string | null; }; @@ -79,14 +82,24 @@ export function buildProductionAdapter( if (!content.trim()) return; const messageId = parentId ?? crypto.randomUUID(); const sessionId = input.sessionId ?? crypto.randomUUID(); - if (!input.sessionId) input.onSessionChange(sessionId); + const runId = crypto.randomUUID(); + input.onOptimisticMessage?.({ + id: messageId, + sessionId, + userId: input.userId ?? "pending-user", + runId, + role: "user", + content, + createdAt: new Date(), + }); await send({ sessionId, messageId, - runId: crypto.randomUUID(), + runId, content, projectId: input.projectId, }); + if (!input.sessionId) input.onSessionChange(sessionId); }; return { diff --git a/packages/web/src/lib/chat-production/fold.ts b/packages/web/src/lib/chat-production/fold.ts index 1ac550e..5a7bc71 100644 --- a/packages/web/src/lib/chat-production/fold.ts +++ b/packages/web/src/lib/chat-production/fold.ts @@ -3,7 +3,8 @@ import type { ThreadMessageLike, ToolCallMessagePart, } from "@assistant-ui/react"; -import type { ChatMessage, ChatRun, ChatRunEvent } from "@/lib/collections"; +import type { ChatMessage, ChatRun } from "@/lib/collections"; +import type { ChatRunEvent } from "./types"; export type ProductionMessage = | { kind: "user"; row: ChatMessage } @@ -49,6 +50,10 @@ export function foldProductionMessages( events: (runEvents.get(run.id) ?? []).sort((a, b) => a.seq - b.seq), })), ].sort((a, b) => { + if (a.kind === "user" && b.kind === "assistant" && a.row.runId === b.run.id) + return -1; + if (a.kind === "assistant" && b.kind === "user" && b.row.runId === a.run.id) + return 1; const aDate = a.kind === "user" ? a.row.createdAt : a.run.createdAt; const bDate = b.kind === "user" ? b.row.createdAt : b.run.createdAt; return aDate.getTime() - bDate.getTime(); diff --git a/packages/web/src/lib/chat-production/types.ts b/packages/web/src/lib/chat-production/types.ts new file mode 100644 index 0000000..96fa452 --- /dev/null +++ b/packages/web/src/lib/chat-production/types.ts @@ -0,0 +1,9 @@ +export type ChatRunEvent = { + runId: string; + sessionId: string; + userId: string | null; + seq: number; + type: "content" | "tool_call" | "tool_result" | "done" | "error"; + payload: Record; + createdAt: Date; +}; diff --git a/packages/web/src/lib/chat-production/use-conversation-stream.ts b/packages/web/src/lib/chat-production/use-conversation-stream.ts index bdb6e44..6672c2d 100644 --- a/packages/web/src/lib/chat-production/use-conversation-stream.ts +++ b/packages/web/src/lib/chat-production/use-conversation-stream.ts @@ -2,16 +2,15 @@ import { useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useRef, useState } from "react"; -import type { ChatMessage, ChatRun, ChatRunEvent } from "@/lib/collections"; +import type { ChatMessage, ChatRun } from "@/lib/collections"; +import type { ChatRunEvent } from "./types"; /** * Live WebSocket subscription to a conversation Durable Object. Provides the - * same shape the Electric event collection delivered — `events`, `messages`, - * `runs` — merged with stale Neon rows on reconnect so the production - * adapter can render running assistant turns immediately. + * Streams `events`, `messages`, and `runs`, merged with stale Neon rows on reconnect so the + * production adapter can render running assistant turns immediately. * - * Replaces `createChatRunEventsCollection` (Electric) with a direct socket - * to the Worker + short-lived HMAC capability ticket. + * Streams directly from the Worker over a short-lived HMAC capability ticket. */ type StreamEnvelope = diff --git a/packages/web/src/lib/chat-production/use-production-chat.tsx b/packages/web/src/lib/chat-production/use-production-chat.tsx index 884c4e2..9366bd3 100644 --- a/packages/web/src/lib/chat-production/use-production-chat.tsx +++ b/packages/web/src/lib/chat-production/use-production-chat.tsx @@ -2,13 +2,16 @@ import { useExternalStoreRuntime } from "@assistant-ui/react"; import { useLiveQuery } from "@tanstack/react-db"; -import { useMemo } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback, useMemo, useState } from "react"; import { useProjectSwitcher } from "@/hooks/use-project-switcher"; +import { useUser } from "@/hooks/use-user"; import { chatMessagesCollection, chatRunsCollection, chatSessionsCollection, } from "@/lib/collections"; +import type { ChatMessage } from "@/lib/collections"; import { buildProductionAdapter, postChatRun } from "./adapter"; import { useConversationStream } from "./use-conversation-stream"; @@ -18,7 +21,9 @@ function mergeById( live: readonly T[], ): T[] { const byId = new Map(neon.map((item) => [item.id, item])); - for (const item of live) byId.set(item.id, item); + for (const item of live) { + byId.set(item.id, { ...byId.get(item.id), ...item }); + } return [...byId.values()]; } @@ -27,6 +32,9 @@ export function useProductionChat( onSessionChange: (sessionId: string) => void, ) { const { activeProject } = useProjectSwitcher(); + const { user } = useUser(); + const queryClient = useQueryClient(); + const [optimisticMessages, setOptimisticMessages] = useState([]); const sessionsQuery = useLiveQuery((q) => q.from({ session: chatSessionsCollection }), @@ -37,6 +45,13 @@ export function useProductionChat( const runsQuery = useLiveQuery((q) => q.from({ run: chatRunsCollection })); const stream = useConversationStream(sessionId); + const send = useCallback( + async (input: Parameters[0]) => { + await postChatRun(input); + await queryClient.invalidateQueries({ queryKey: ["chat-sessions"] }); + }, + [queryClient], + ); const sessions = useMemo( () => @@ -71,8 +86,8 @@ export function useProductionChat( // materialised yet; for completed runs, Neon is authoritative and will // be used by the merge when the same id appears in both. const messages = useMemo( - () => mergeById(neonMessages, stream.messages), - [neonMessages, stream.messages], + () => mergeById(mergeById(optimisticMessages, neonMessages), stream.messages), + [neonMessages, optimisticMessages, stream.messages], ); const runs = useMemo( () => mergeById(neonRuns, stream.runs), @@ -88,12 +103,15 @@ export function useProductionChat( messages, runs, events: stream.events, + userId: user?.id, + onOptimisticMessage: (message) => + setOptimisticMessages((current) => mergeById(current, [message])), onSessionChange: (nextSessionId) => { if (nextSessionId) onSessionChange(nextSessionId); }, projectId: activeProject?.id ?? null, }, - postChatRun, + send, ), [ activeProject?.id, @@ -101,8 +119,11 @@ export function useProductionChat( sessionId, sessions, messages, + user?.id, + optimisticMessages, runs, stream.events, + send, ], ); diff --git a/packages/web/src/lib/collections.ts b/packages/web/src/lib/collections.ts index 36cfccf..17c1434 100644 --- a/packages/web/src/lib/collections.ts +++ b/packages/web/src/lib/collections.ts @@ -1,34 +1,12 @@ import { selectUserSchema } from "@core/auth/auth.sql"; -import { snakeCamelMapper } from "@electric-sql/client"; -import { electricCollectionOptions } from "@tanstack/electric-db-collection"; import { createCollection } from "@tanstack/react-db"; import { queryCollectionOptions } from "@tanstack/query-db-collection"; import { queryClient } from "@/lib/query-client"; import { trpc } from "@/lib/trpc-client"; +import { normalizeRowDates } from "@/lib/row-dates"; +import { normalizeUserRow } from "@/lib/user-row"; import { z } from "zod"; -/** - * Parse a Postgres timestamp from the Electric wire format into a Date. - * - * Electric serves values as strings in Postgres text format: - * - `timestamp` → "2026-07-17 08:23:41.123456" (no offset; stored as UTC) - * - `timestamptz` → "2026-07-17 08:23:41.123456+00" (with offset) - * - * The default Electric parser leaves timestamps as strings, but our collection - * schema (drizzle-zod) expects Date objects. - */ -const parsePgTimestamp = (value: string): Date => { - const iso = value - .replace(" ", "T") - // Normalize "+00" → "+00:00" so Date parsing is reliable cross-browser - .replace( - /([+-]\d{2})(\d{2})?$/, - (_match, hours, minutes) => `${hours}:${minutes ?? "00"}`, - ); - // `timestamp` columns carry no offset — drizzle writes UTC via toISOString() - return new Date(/(Z|[+-]\d{2}:\d{2})$/.test(iso) ? iso : `${iso}Z`); -}; - /** * Get the API base URL for client-side requests. * Returns absolute URL to avoid "Invalid URL" errors during SSR. @@ -48,7 +26,7 @@ const getApiBase = (): string => { ); }; -/** Users collection backed by the authenticated API and TanStack Query. */ +/** Users collection backed by the authenticated tRPC API and TanStack Query. */ export const usersCollection = createCollection( queryCollectionOptions({ id: "users", @@ -56,12 +34,8 @@ export const usersCollection = createCollection( queryKey: ["users", "me"], queryClient, queryFn: async () => { - const response = await fetch(`${getApiBase()}/api/users`, { - credentials: "include", - }); - if (!response.ok) - throw new Error(`Failed to load user (${response.status})`); - return [selectUserSchema.parse(await response.json())]; + const user = await trpc.users.get.query(); + return [selectUserSchema.parse(normalizeUserRow(user))]; }, getKey: (item) => item.id, onUpdate: async ({ transaction }) => { @@ -99,7 +73,9 @@ export const chatSessionsCollection = createCollection( if (!response.ok) throw new Error(`Failed to load conversations (${response.status})`); const result = (await response.json()) as { items: unknown[] }; - return result.items.map((item) => chatSessionSchema.parse(item)); + return result.items.map((item) => + chatSessionSchema.parse(normalizeRowDates(item)), + ); }, getKey: (row) => row.id, }), @@ -126,24 +102,6 @@ const chatRunSchema = z.object({ updatedAt: z.date(), }); -const chatRunEventSchema = z.object({ - runId: z.string(), - sessionId: z.string(), - userId: z.string().nullable(), - seq: z.number(), - type: z.enum(["content", "tool_call", "tool_result", "done", "error"]), - payload: z.record(z.string(), z.unknown()), - createdAt: z.date(), -}); - -const chatShapeOptions = (path: string) => ({ - get url() { - return `${getApiBase()}${path}`; - }, - columnMapper: snakeCamelMapper(), - parser: { timestamp: parsePgTimestamp, timestamptz: parsePgTimestamp }, -}); - const fetchChatRows = async (path: string, schema: z.ZodType) => { const response = await fetch(`${getApiBase()}${path}`, { credentials: "include", @@ -151,7 +109,7 @@ const fetchChatRows = async (path: string, schema: z.ZodType) => { if (!response.ok) throw new Error(`Failed to load chat data (${response.status})`); const rows = (await response.json()) as unknown[]; - return rows.map((row) => schema.parse(row)); + return rows.map((row) => schema.parse(normalizeRowDates(row))); }; export const chatMessagesCollection = createCollection( @@ -176,20 +134,6 @@ export const chatRunsCollection = createCollection( }), ); -/** Events are session-scoped so callers cannot accidentally subscribe globally. */ -export const createChatRunEventsCollection = (sessionId: string) => - createCollection( - electricCollectionOptions({ - id: `chat_run_events:${sessionId}`, - schema: chatRunEventSchema, - getKey: (row) => `${row.runId}:${row.seq}`, - shapeOptions: chatShapeOptions( - `/api/chat/events?sessionId=${encodeURIComponent(sessionId)}`, - ), - }), - ); - export type ChatSession = z.infer; export type ChatMessage = z.infer; export type ChatRun = z.infer; -export type ChatRunEvent = z.infer; diff --git a/packages/web/src/lib/electric-proxy.ts b/packages/web/src/lib/electric-proxy.ts deleted file mode 100644 index 37f17d4..0000000 --- a/packages/web/src/lib/electric-proxy.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { ELECTRIC_PROTOCOL_QUERY_PARAMS } from "@electric-sql/client"; -import { Resource } from "sst"; - -/** - * Electric Cloud base URL - * The source_id is passed as a query parameter, not part of the URL - */ -const ELECTRIC_CLOUD_BASE_URL = "https://api.electric-sql.cloud/v1/shape"; - -/** - * Electric Cloud management API base URL (the same @orpc RPC protocol the - * SST ElectricCloudSync dynamic resource uses). On preview stages the sync - * source id + secret are created at deploy time and cannot be baked into - * the SyncEngine link, so we resolve them at runtime via this API. - */ -const ELECTRIC_CLOUD_API = "https://dashboard.electric-sql.cloud/api/rpc"; - -let cachedSource: string | undefined; -let cachedSecret: string | undefined; - -/** - * Call an Electric Cloud RPC endpoint. - * - * The @orpc/client protocol wraps params in {"json": {…}} and unwraps - * responses from {"json": {…}}. Errors also arrive as HTTP 200 with a - * NOT_FOUND / etc. code inside the json envelope. - */ -async function rpcCall>( - method: string, - token: string, - params: Record, -): Promise { - const res = await fetch(`${ELECTRIC_CLOUD_API}/${method}`, { - method: "POST", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ json: params }), - }); - - if (!res.ok) { - throw new Error(`Electric Cloud API POST ${method} failed (${res.status})`); - } - - const body = (await res.json()) as { - json?: T & { code?: string; message?: string }; - }; - - if (body.json && "code" in body.json && body.json.code === "NOT_FOUND") { - throw new Error(body.json.message ?? "Electric Cloud resource not found"); - } - - return body.json as T; -} - -/** Clear the module-level credential cache (used by tests). */ -export function resetElectricCredentialsCache(): void { - cachedSource = undefined; - cachedSecret = undefined; -} - -/** - * Resolve the Electric sync source id + secret for this stage. - * - * - Deployed stages (dev, production): the SyncEngine link bakes the static - * ElectricSqlSource / ElectricSqlSecret secret values, so use those - * directly (no API call). - * - Preview/personal stages: the source id + secret are created at deploy - * time by the ElectricCloudSync dynamic resource, whose outputs cannot be - * serialized into the link. Resolve them at runtime from the Electric - * Cloud management API using the linked API token. The environment name is - * deterministic (`preview-{stage}`), so no extra config is needed. - * - * Results are cached for the lifetime of the Lambda container. - */ -async function resolveElectricCredentials(): Promise<{ - source: string; - secret: string; -}> { - if (cachedSource && cachedSecret) { - return { source: cachedSource, secret: cachedSecret }; - } - - // 1. Static link (dev/production). - const syncEngine = Resource.SyncEngine as typeof Resource.SyncEngine & { - source?: string; - secret?: string; - }; - const staticSource = syncEngine?.source; - const staticSecret = syncEngine?.secret; - if (staticSource && staticSecret) { - cachedSource = staticSource; - cachedSecret = staticSecret; - return { source: staticSource, secret: staticSecret }; - } - - // 2. Runtime lookup (preview stages). - const token = Resource.ElectricCloudApiToken?.value; - const projectId = Resource.ElectricCloudProjectId?.value; - if (!token || !projectId) { - throw new Error( - `Cannot resolve Electric credentials: no SyncEngine link and no ` + - `ElectricCloudApiToken/ElectricCloudProjectId. Got: ` + - JSON.stringify(Resource.SyncEngine), - ); - } - - // a. Find the environment created for this stage. - const envName = `preview-${Resource.App.stage}`; - const envs = await rpcCall<{ - environments: Array<{ id: string; name: string }>; - }>("environments/list", token, { projectId }); - const env = envs.environments.find((e) => e.name === envName); - if (!env) { - throw new Error( - `Electric Cloud environment '${envName}' not found. It is created ` + - `during the first deploy of this stage; redeploy if missing.`, - ); - } - - // b. Find the postgres sync service. - const svcs = await rpcCall<{ - services: Array<{ id: string }>; - }>("services/list", token, { environmentId: env.id }); - const service = svcs.services.find((s) => s.id); - if (!service) { - throw new Error( - `No Electric Cloud sync service found in environment '${envName}'.`, - ); - } - - // c. Fetch the service secret. - const secretResult = await rpcCall<{ secret: string }>( - "services/getSecret", - token, - { serviceId: service.id }, - ); - - cachedSource = service.id; - cachedSecret = secretResult.secret; - return { source: service.id, secret: secretResult.secret }; -} - -/** - * Get the Electric source ID (resolved per stage — see - * resolveElectricCredentials). - */ -export async function getElectricSourceId(): Promise { - return (await resolveElectricCredentials()).source; -} - -/** - * Get the Electric secret for authentication (resolved per stage — see - * resolveElectricCredentials). - */ -export async function getElectricSecret(): Promise { - return (await resolveElectricCredentials()).secret; -} - -/** - * Build the upstream Electric URL with proper parameters - * Only passes through Electric protocol parameters and sets server-side shape config - */ -export async function buildElectricUpstreamUrl( - request: Request, - table: string, - whereClause?: string, -): Promise { - const { source, secret } = await resolveElectricCredentials(); - const requestUrl = new URL(request.url); - // Use the Electric Cloud base URL - const originUrl = new URL(ELECTRIC_CLOUD_BASE_URL); - - // Pass through Electric protocol parameters (offset, handle, live, etc.) - requestUrl.searchParams.forEach((value, key) => { - if (ELECTRIC_PROTOCOL_QUERY_PARAMS.includes(key)) { - originUrl.searchParams.set(key, value); - } - }); - - // Set shape definition server-side (security: these must be controlled by server) - originUrl.searchParams.set("source_id", source); - originUrl.searchParams.set("secret", secret); - originUrl.searchParams.set("table", table); - - if (whereClause) { - originUrl.searchParams.set("where", whereClause); - } - - console.debug("[electric-proxy] Built URL:", originUrl.toString()); - - return originUrl; -} - -/** - * Proxy a request to Electric Cloud and return the response - * Handles proper header cleanup for browser compatibility - */ -export async function proxyToElectric(originUrl: URL): Promise { - const response = await fetch(originUrl); - - // Clean up headers that would break decoding in the browser - // See: https://github.com/whatwg/fetch/issues/1729 - const headers = new Headers(response.headers); - headers.delete("content-encoding"); - headers.delete("content-length"); - - // Add Vary header for proper cache isolation with cookie-based auth - headers.set("Vary", "Cookie"); - - return new Response(response.body, { - status: response.status, - statusText: response.statusText, - headers, - }); -} diff --git a/packages/web/src/lib/row-dates.ts b/packages/web/src/lib/row-dates.ts new file mode 100644 index 0000000..54fea27 --- /dev/null +++ b/packages/web/src/lib/row-dates.ts @@ -0,0 +1,17 @@ +const normalizeDate = (value: unknown): Date | null => { + if (value === null || value === undefined) return null; + return value instanceof Date ? value : new Date(String(value)); +}; + +/** Normalize timestamp fields serialized by JSON APIs before Zod validation. */ +export function normalizeRowDates(row: unknown): unknown { + if (!row || typeof row !== "object") return row; + + const value = row as Record; + const normalized = { ...value }; + if ("createdAt" in value) normalized.createdAt = normalizeDate(value.createdAt); + if ("updatedAt" in value) normalized.updatedAt = normalizeDate(value.updatedAt); + if ("lastMessageAt" in value) + normalized.lastMessageAt = normalizeDate(value.lastMessageAt); + return normalized; +} diff --git a/packages/web/src/lib/trpc-client.ts b/packages/web/src/lib/trpc-client.ts index fb320a3..187f50e 100644 --- a/packages/web/src/lib/trpc-client.ts +++ b/packages/web/src/lib/trpc-client.ts @@ -19,11 +19,3 @@ export const trpc = createTRPCProxyClient({ }), ], }); - -/** - * Type-safe mutation result with txid for Electric sync confirmation - */ -export interface MutationResult { - data: T; - txid: number; -} diff --git a/packages/web/src/lib/trpc.ts b/packages/web/src/lib/trpc.ts index 7cbbd29..8753ceb 100644 --- a/packages/web/src/lib/trpc.ts +++ b/packages/web/src/lib/trpc.ts @@ -1,6 +1,5 @@ import { initTRPC } from "@trpc/server"; import type { Session, User } from "better-auth/types"; -import { sql } from "drizzle-orm"; import { auth } from "@/lib/auth"; /** @@ -55,21 +54,3 @@ export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => { }, }); }); - -/** - * SQL fragment that returns the current Postgres transaction ID as text. - * - * The `::xid` cast strips the epoch, giving the raw 32-bit value that Postgres - * sends in logical replication streams — which is what Electric exposes as - * `headers.txids` in the sync stream and what TanStack DB's `awaitTxId` - * matches against. - * - * IMPORTANT: This MUST be embedded in the SAME statement as the mutation - * (e.g. in its RETURNING clause). neon-http runs each statement in its own - * implicit transaction, so reading `pg_current_xact_id()` in a separate query - * returns a different txid that never appears in the sync stream — causing - * `awaitTxId` to stall and time out, rolling back the optimistic update. - * - * @see https://tanstack.com/db/latest/docs/collections/electric-collection#debugging - */ -export const pgCurrentTxId = sql`pg_current_xact_id()::xid::text`; diff --git a/packages/web/src/lib/trpc/__tests__/users.test.ts b/packages/web/src/lib/trpc/__tests__/users.test.ts index 3e9f6a9..ab7950a 100644 --- a/packages/web/src/lib/trpc/__tests__/users.test.ts +++ b/packages/web/src/lib/trpc/__tests__/users.test.ts @@ -128,8 +128,6 @@ vi.mock("@/lib/trpc", () => { if (!ctx.user) throw new Error("UNAUTHORIZED"); return next({ ctx }); }), - // SQL fragment stub — the db chain is mocked so this is never executed - pgCurrentTxId: { sql: "pg_current_xact_id()::xid::text" }, }; }); @@ -224,9 +222,7 @@ describe("tRPC Users Router", () => { }); describe("update procedure", () => { - it("updates user name and returns txid", async () => { - // The UPDATE ... RETURNING includes pg_current_xact_id() as `txid` - // (returned by Postgres as text) + it("updates user name and returns the updated row", async () => { const returnedRow = { id: "user-123", name: "Updated Name", @@ -240,7 +236,6 @@ describe("tRPC Users Router", () => { role: null, plan: "pro", product: null, - txid: "756", }; // Set up the mock chain: update().set().where().returning() @@ -254,13 +249,10 @@ describe("tRPC Users Router", () => { const caller = usersRouter.createCaller(createMockContext()); const result = await caller.update({ name: "Updated Name" }); - expect(result.data.name).toBe("Updated Name"); - // txid is parsed to a number and stripped from the user data - expect(result.txid).toBe(756); - expect(result.data).not.toHaveProperty("txid"); + expect(result.name).toBe("Updated Name"); }); - it("updates user image and returns txid", async () => { + it("updates user image and returns the updated row", async () => { const returnedRow = { id: "user-123", name: "Test User", @@ -274,7 +266,6 @@ describe("tRPC Users Router", () => { role: null, plan: "pro", product: null, - txid: "757", }; const mockReturning = vi.fn().mockResolvedValue([returnedRow]); @@ -289,8 +280,7 @@ describe("tRPC Users Router", () => { image: "https://example.com/avatar.png", }); - expect(result.data.image).toBe("https://example.com/avatar.png"); - expect(result.txid).toBe(757); + expect(result.image).toBe("https://example.com/avatar.png"); }); it("allows setting image to null", async () => { @@ -307,7 +297,6 @@ describe("tRPC Users Router", () => { role: null, plan: "pro", product: null, - txid: "758", }; const mockReturning = vi.fn().mockResolvedValue([returnedRow]); @@ -320,38 +309,7 @@ describe("tRPC Users Router", () => { const caller = usersRouter.createCaller(createMockContext()); const result = await caller.update({ image: null }); - expect(result.data.image).toBeNull(); - }); - - it("throws error when the returned txid is not a number", async () => { - const returnedRow = { - id: "user-123", - name: "Test User", - email: "test@example.com", - emailVerified: true, - image: null, - createdAt: new Date("2024-01-01"), - updatedAt: new Date(), - workspaceId: null, - workspaceName: null, - role: null, - plan: "pro", - product: null, - txid: undefined, - }; - - const mockReturning = vi.fn().mockResolvedValue([returnedRow]); - const mockUpdateWhere = vi - .fn() - .mockReturnValue({ returning: mockReturning }); - const mockSet = vi.fn().mockReturnValue({ where: mockUpdateWhere }); - (db.update as ReturnType).mockReturnValue({ set: mockSet }); - - const caller = usersRouter.createCaller(createMockContext()); - - await expect(caller.update({ name: "New Name" })).rejects.toThrow( - "TXID_FAILED", - ); + expect(result.image).toBeNull(); }); it("throws error when update fails", async () => { diff --git a/packages/web/src/lib/trpc/storage.ts b/packages/web/src/lib/trpc/storage.ts index 9724c71..a2d9f33 100644 --- a/packages/web/src/lib/trpc/storage.ts +++ b/packages/web/src/lib/trpc/storage.ts @@ -19,7 +19,7 @@ export const storageRouter = router({ * Get a presigned S3 PUT URL for the current user's avatar. * * Returns `{ url, key }`. The caller then PUTs the raw File to `url` - * and persists `key` to the user row via the optimistic Electric + * and persists `key` to the user row via the optimistic * collection update flow (see `use-update-user.ts`). */ uploadUserImage: protectedProcedure diff --git a/packages/web/src/lib/trpc/users.ts b/packages/web/src/lib/trpc/users.ts index ceb0e79..ab662e8 100644 --- a/packages/web/src/lib/trpc/users.ts +++ b/packages/web/src/lib/trpc/users.ts @@ -2,11 +2,11 @@ import { selectUserSchema, user } from "@core/auth/auth.sql"; import { db } from "@core/drizzle"; import { eq, getTableColumns } from "drizzle-orm"; import { z } from "zod"; -import { pgCurrentTxId, protectedProcedure, router } from "@/lib/trpc"; +import { protectedProcedure, router } from "@/lib/trpc"; /** * Users tRPC router - * Provides type-safe mutations for user data with txid generation for Electric sync + * Provides type-safe reads and mutations for user profile data. */ export const usersRouter = router({ /** @@ -29,14 +29,9 @@ export const usersRouter = router({ /** * Update the current user's profile * - * Returns the Postgres txid of the mutation so the Electric collection can - * wait for the change to sync back (optimistic update confirmation). - * - * The txid is read with `pg_current_xact_id()` in the RETURNING clause of - * the UPDATE itself. neon-http executes each statement in its own implicit - * transaction, so this is the same transaction that performs the write — - * a separate `SELECT pg_current_xact_id()` would return a different txid - * that never appears in the Electric stream. + * Persists the update directly to Neon so the users Query Collection picks + * it up on its next refetch. Optimistic UI confirmation is handled by the + * collection's own persistence tracking. */ update: protectedProcedure .input( @@ -60,23 +55,12 @@ export const usersRouter = router({ .where(eq(user.id, ctx.user.id)) .returning({ ...getTableColumns(user), - // Same-statement read → same transaction as the write - txid: pgCurrentTxId, }); if (!row) { throw new Error("UPDATE_FAILED"); } - const { txid, ...updatedUser } = row; - const parsedTxid = Number.parseInt(txid, 10); - if (Number.isNaN(parsedTxid)) { - throw new Error("TXID_FAILED"); - } - - return { - data: selectUserSchema.parse(updatedUser), - txid: parsedTxid, - }; + return selectUserSchema.parse(row); }), }); diff --git a/packages/web/src/lib/user-row.ts b/packages/web/src/lib/user-row.ts new file mode 100644 index 0000000..1b509d2 --- /dev/null +++ b/packages/web/src/lib/user-row.ts @@ -0,0 +1,16 @@ +const parseDate = (value: unknown): Date => + value instanceof Date ? value : new Date(String(value)); + +/** Normalize JSON-serialized user timestamps before schema validation. */ +export const normalizeUserRow = (row: unknown) => { + if (!row || typeof row !== "object") { + return row; + } + + const user = row as Record; + return { + ...user, + createdAt: parseDate(user.createdAt), + updatedAt: parseDate(user.updatedAt), + }; +}; diff --git a/packages/web/src/routeTree.gen.ts b/packages/web/src/routeTree.gen.ts index e14a9a0..3187ce4 100644 --- a/packages/web/src/routeTree.gen.ts +++ b/packages/web/src/routeTree.gen.ts @@ -17,7 +17,6 @@ import { Route as MarketingIndexRouteImport } from './routes/_marketing/index' import { Route as UseCasesLoftRouteImport } from './routes/use-cases/loft' import { Route as UseCasesKitchenRouteImport } from './routes/use-cases/kitchen' import { Route as UseCasesExtensionRouteImport } from './routes/use-cases/extension' -import { Route as ApiUsersRouteImport } from './routes/api/users' import { Route as MarketingTmpRouteImport } from './routes/_marketing/tmp' import { Route as MarketingTermsRouteImport } from './routes/_marketing/terms' import { Route as MarketingPrivacyRouteImport } from './routes/_marketing/privacy' @@ -31,11 +30,9 @@ import { Route as AuthAppIndexRouteImport } from './routes/_auth/app/index' import { Route as ApiTrpcSplatRouteImport } from './routes/api/trpc/$' import { Route as ApiChatTicketRouteImport } from './routes/api/chat/ticket' import { Route as ApiChatStreamEventsRouteImport } from './routes/api/chat/stream-events' -import { Route as ApiChatSessionsRouteImport } from './routes/api/chat/sessions' import { Route as ApiChatRunsRouteImport } from './routes/api/chat/runs' import { Route as ApiChatRunRouteImport } from './routes/api/chat/run' import { Route as ApiChatMessagesRouteImport } from './routes/api/chat/messages' -import { Route as ApiChatEventsRouteImport } from './routes/api/chat/events' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' import { Route as MarketingGuidesSlugRouteImport } from './routes/_marketing/guides/$slug' import { Route as LoginLoginCodeRouteImport } from './routes/_login/login/code' @@ -88,11 +85,6 @@ const UseCasesExtensionRoute = UseCasesExtensionRouteImport.update({ path: '/use-cases/extension', getParentRoute: () => rootRouteImport, } as any) -const ApiUsersRoute = ApiUsersRouteImport.update({ - id: '/api/users', - path: '/api/users', - getParentRoute: () => rootRouteImport, -} as any) const MarketingTmpRoute = MarketingTmpRouteImport.update({ id: '/tmp', path: '/tmp', @@ -156,11 +148,6 @@ const ApiChatStreamEventsRoute = ApiChatStreamEventsRouteImport.update({ path: '/api/chat/stream-events', getParentRoute: () => rootRouteImport, } as any) -const ApiChatSessionsRoute = ApiChatSessionsRouteImport.update({ - id: '/api/chat/sessions', - path: '/api/chat/sessions', - getParentRoute: () => rootRouteImport, -} as any) const ApiChatRunsRoute = ApiChatRunsRouteImport.update({ id: '/api/chat/runs', path: '/api/chat/runs', @@ -176,11 +163,6 @@ const ApiChatMessagesRoute = ApiChatMessagesRouteImport.update({ path: '/api/chat/messages', getParentRoute: () => rootRouteImport, } as any) -const ApiChatEventsRoute = ApiChatEventsRouteImport.update({ - id: '/api/chat/events', - path: '/api/chat/events', - getParentRoute: () => rootRouteImport, -} as any) const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({ id: '/api/auth/$', path: '/api/auth/$', @@ -268,7 +250,6 @@ export interface FileRoutesByFullPath { '/privacy': typeof MarketingPrivacyRoute '/terms': typeof MarketingTermsRoute '/tmp': typeof MarketingTmpRoute - '/api/users': typeof ApiUsersRoute '/use-cases/extension': typeof UseCasesExtensionRoute '/use-cases/kitchen': typeof UseCasesKitchenRoute '/use-cases/loft': typeof UseCasesLoftRoute @@ -278,11 +259,9 @@ export interface FileRoutesByFullPath { '/login/code': typeof LoginLoginCodeRoute '/guides/$slug': typeof MarketingGuidesSlugRoute '/api/auth/$': typeof ApiAuthSplatRoute - '/api/chat/events': typeof ApiChatEventsRoute '/api/chat/messages': typeof ApiChatMessagesRoute '/api/chat/run': typeof ApiChatRunRoute '/api/chat/runs': typeof ApiChatRunsRoute - '/api/chat/sessions': typeof ApiChatSessionsRoute '/api/chat/stream-events': typeof ApiChatStreamEventsRoute '/api/chat/ticket': typeof ApiChatTicketRoute '/api/trpc/$': typeof ApiTrpcSplatRoute @@ -306,7 +285,6 @@ export interface FileRoutesByTo { '/privacy': typeof MarketingPrivacyRoute '/terms': typeof MarketingTermsRoute '/tmp': typeof MarketingTmpRoute - '/api/users': typeof ApiUsersRoute '/use-cases/extension': typeof UseCasesExtensionRoute '/use-cases/kitchen': typeof UseCasesKitchenRoute '/use-cases/loft': typeof UseCasesLoftRoute @@ -316,11 +294,9 @@ export interface FileRoutesByTo { '/login/code': typeof LoginLoginCodeRoute '/guides/$slug': typeof MarketingGuidesSlugRoute '/api/auth/$': typeof ApiAuthSplatRoute - '/api/chat/events': typeof ApiChatEventsRoute '/api/chat/messages': typeof ApiChatMessagesRoute '/api/chat/run': typeof ApiChatRunRoute '/api/chat/runs': typeof ApiChatRunsRoute - '/api/chat/sessions': typeof ApiChatSessionsRoute '/api/chat/stream-events': typeof ApiChatStreamEventsRoute '/api/chat/ticket': typeof ApiChatTicketRoute '/api/trpc/$': typeof ApiTrpcSplatRoute @@ -349,7 +325,6 @@ export interface FileRoutesById { '/_marketing/privacy': typeof MarketingPrivacyRoute '/_marketing/terms': typeof MarketingTermsRoute '/_marketing/tmp': typeof MarketingTmpRoute - '/api/users': typeof ApiUsersRoute '/use-cases/extension': typeof UseCasesExtensionRoute '/use-cases/kitchen': typeof UseCasesKitchenRoute '/use-cases/loft': typeof UseCasesLoftRoute @@ -360,11 +335,9 @@ export interface FileRoutesById { '/_login/login/code': typeof LoginLoginCodeRoute '/_marketing/guides/$slug': typeof MarketingGuidesSlugRoute '/api/auth/$': typeof ApiAuthSplatRoute - '/api/chat/events': typeof ApiChatEventsRoute '/api/chat/messages': typeof ApiChatMessagesRoute '/api/chat/run': typeof ApiChatRunRoute '/api/chat/runs': typeof ApiChatRunsRoute - '/api/chat/sessions': typeof ApiChatSessionsRoute '/api/chat/stream-events': typeof ApiChatStreamEventsRoute '/api/chat/ticket': typeof ApiChatTicketRoute '/api/trpc/$': typeof ApiTrpcSplatRoute @@ -390,7 +363,6 @@ export interface FileRouteTypes { | '/privacy' | '/terms' | '/tmp' - | '/api/users' | '/use-cases/extension' | '/use-cases/kitchen' | '/use-cases/loft' @@ -400,11 +372,9 @@ export interface FileRouteTypes { | '/login/code' | '/guides/$slug' | '/api/auth/$' - | '/api/chat/events' | '/api/chat/messages' | '/api/chat/run' | '/api/chat/runs' - | '/api/chat/sessions' | '/api/chat/stream-events' | '/api/chat/ticket' | '/api/trpc/$' @@ -428,7 +398,6 @@ export interface FileRouteTypes { | '/privacy' | '/terms' | '/tmp' - | '/api/users' | '/use-cases/extension' | '/use-cases/kitchen' | '/use-cases/loft' @@ -438,11 +407,9 @@ export interface FileRouteTypes { | '/login/code' | '/guides/$slug' | '/api/auth/$' - | '/api/chat/events' | '/api/chat/messages' | '/api/chat/run' | '/api/chat/runs' - | '/api/chat/sessions' | '/api/chat/stream-events' | '/api/chat/ticket' | '/api/trpc/$' @@ -470,7 +437,6 @@ export interface FileRouteTypes { | '/_marketing/privacy' | '/_marketing/terms' | '/_marketing/tmp' - | '/api/users' | '/use-cases/extension' | '/use-cases/kitchen' | '/use-cases/loft' @@ -481,11 +447,9 @@ export interface FileRouteTypes { | '/_login/login/code' | '/_marketing/guides/$slug' | '/api/auth/$' - | '/api/chat/events' | '/api/chat/messages' | '/api/chat/run' | '/api/chat/runs' - | '/api/chat/sessions' | '/api/chat/stream-events' | '/api/chat/ticket' | '/api/trpc/$' @@ -507,16 +471,13 @@ export interface RootRouteChildren { LoginRoute: typeof LoginRouteWithChildren MarketingRoute: typeof MarketingRouteWithChildren BlogRoute: typeof BlogRoute - ApiUsersRoute: typeof ApiUsersRoute UseCasesExtensionRoute: typeof UseCasesExtensionRoute UseCasesKitchenRoute: typeof UseCasesKitchenRoute UseCasesLoftRoute: typeof UseCasesLoftRoute ApiAuthSplatRoute: typeof ApiAuthSplatRoute - ApiChatEventsRoute: typeof ApiChatEventsRoute ApiChatMessagesRoute: typeof ApiChatMessagesRoute ApiChatRunRoute: typeof ApiChatRunRoute ApiChatRunsRoute: typeof ApiChatRunsRoute - ApiChatSessionsRoute: typeof ApiChatSessionsRoute ApiChatStreamEventsRoute: typeof ApiChatStreamEventsRoute ApiChatTicketRoute: typeof ApiChatTicketRoute ApiTrpcSplatRoute: typeof ApiTrpcSplatRoute @@ -582,13 +543,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof UseCasesExtensionRouteImport parentRoute: typeof rootRouteImport } - '/api/users': { - id: '/api/users' - path: '/api/users' - fullPath: '/api/users' - preLoaderRoute: typeof ApiUsersRouteImport - parentRoute: typeof rootRouteImport - } '/_marketing/tmp': { id: '/_marketing/tmp' path: '/tmp' @@ -680,13 +634,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiChatStreamEventsRouteImport parentRoute: typeof rootRouteImport } - '/api/chat/sessions': { - id: '/api/chat/sessions' - path: '/api/chat/sessions' - fullPath: '/api/chat/sessions' - preLoaderRoute: typeof ApiChatSessionsRouteImport - parentRoute: typeof rootRouteImport - } '/api/chat/runs': { id: '/api/chat/runs' path: '/api/chat/runs' @@ -708,13 +655,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiChatMessagesRouteImport parentRoute: typeof rootRouteImport } - '/api/chat/events': { - id: '/api/chat/events' - path: '/api/chat/events' - fullPath: '/api/chat/events' - preLoaderRoute: typeof ApiChatEventsRouteImport - parentRoute: typeof rootRouteImport - } '/api/auth/$': { id: '/api/auth/$' path: '/api/auth/$' @@ -912,16 +852,13 @@ const rootRouteChildren: RootRouteChildren = { LoginRoute: LoginRouteWithChildren, MarketingRoute: MarketingRouteWithChildren, BlogRoute: BlogRoute, - ApiUsersRoute: ApiUsersRoute, UseCasesExtensionRoute: UseCasesExtensionRoute, UseCasesKitchenRoute: UseCasesKitchenRoute, UseCasesLoftRoute: UseCasesLoftRoute, ApiAuthSplatRoute: ApiAuthSplatRoute, - ApiChatEventsRoute: ApiChatEventsRoute, ApiChatMessagesRoute: ApiChatMessagesRoute, ApiChatRunRoute: ApiChatRunRoute, ApiChatRunsRoute: ApiChatRunsRoute, - ApiChatSessionsRoute: ApiChatSessionsRoute, ApiChatStreamEventsRoute: ApiChatStreamEventsRoute, ApiChatTicketRoute: ApiChatTicketRoute, ApiTrpcSplatRoute: ApiTrpcSplatRoute, diff --git a/packages/web/src/routes/_auth.tsx b/packages/web/src/routes/_auth.tsx index 3072487..cae1745 100644 --- a/packages/web/src/routes/_auth.tsx +++ b/packages/web/src/routes/_auth.tsx @@ -17,7 +17,7 @@ import { useUser } from "@/hooks/use-user"; import { getAuth } from "@/lib/auth-server"; export const Route = createFileRoute("/_auth")({ - ssr: false, // Required for Electric collections + ssr: false, // Required for client-side Query Collections beforeLoad: async ({ location }) => { const session = await getAuth(); @@ -54,7 +54,7 @@ function AuthLayout() { // Call ALL hooks before any early returns to satisfy React's Rules of Hooks const { user, isLoading } = useUser(); - // Onboarding routes are standalone (no sidebar, no Electric) + // Onboarding routes are standalone (no sidebar) if (pathname.startsWith("/onboarding")) { return ; } @@ -75,12 +75,12 @@ function AuthLayout() { }); const currentTitle = isConversationRoute ? undefined : crumbs.at(-1)?.title; - // Show loading screen while Electric syncs + // Show loading screen while the user collection loads if (isLoading) { return ; } - // Use Electric user if synced, fallback to auth user. + // Use collection user if loaded, fallback to auth user. // Spread preserves all fields so NavUser can access id, plan, etc. const rawUser = user ?? authUser; const displayUser: SidebarUser = { diff --git a/packages/web/src/routes/_auth/_onboarding/onboarding/index.tsx b/packages/web/src/routes/_auth/_onboarding/onboarding/index.tsx index 1fbbf7b..d18e56c 100644 --- a/packages/web/src/routes/_auth/_onboarding/onboarding/index.tsx +++ b/packages/web/src/routes/_auth/_onboarding/onboarding/index.tsx @@ -7,6 +7,7 @@ import { useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { HomeIconLink } from "@/components/ui/link"; +import { useUser } from "@/hooks/use-user"; interface WaitlistInput { email: string; @@ -63,10 +64,13 @@ export const Route = createFileRoute("/_auth/_onboarding/onboarding/")({ function OnboardingPage() { const { authUser } = Route.useRouteContext(); + const { user } = useUser(); const navigate = useNavigate(); + const currentUser = user ?? authUser; - const isOnWaitlist = authUser.plan === "waitlist"; - const userHasName = authUser.name != null && authUser.name.trim().length > 0; + const isOnWaitlist = currentUser.plan === "waitlist"; + const userHasName = + currentUser.name != null && currentUser.name.trim().length > 0; const [name, setName] = useState(""); const joinWaitlistMutation = useMutation({ @@ -84,15 +88,15 @@ function OnboardingPage() { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (!authUser.email || !authUser.id) { + if (!currentUser.email || !currentUser.id) { return; } joinWaitlistMutation.mutate({ data: { - email: authUser.email, - userId: authUser.id, - name: !userHasName ? name || undefined : authUser.name || undefined, + email: currentUser.email, + userId: currentUser.id, + name: !userHasName ? name || undefined : currentUser.name || undefined, updateName: !userHasName && !!name, }, }); @@ -138,7 +142,7 @@ function OnboardingPage() { disabled={ isOnWaitlist || joinWaitlistMutation.isPending || - !authUser.email + !currentUser.email } > {isOnWaitlist diff --git a/packages/web/src/routes/_auth/_settings/settings/account.tsx b/packages/web/src/routes/_auth/_settings/settings/account.tsx index 7d9a242..fbeec03 100644 --- a/packages/web/src/routes/_auth/_settings/settings/account.tsx +++ b/packages/web/src/routes/_auth/_settings/settings/account.tsx @@ -44,7 +44,7 @@ function AccountSettings() { // Get user from route context as fallback const { authUser } = Route.useRouteContext(); - // Use Electric user if synced, fallback to auth context + // Use collection user if loaded, fallback to auth context const currentUser = user ?? authUser; // Initialize the mutation hook with the current user's ID @@ -80,7 +80,7 @@ function AccountSettings() { }, }); - // Update form when user data changes from Electric sync + // Update form when user data changes from the collection // Use primitive values as dependencies to avoid object reference issues useEffect(() => { const currentName = form.getValues("name"); @@ -162,7 +162,7 @@ function AccountSettings() { } }, [fileRejections]); - // Show loading state while Electric syncs. + // Show loading state while the user collection loads. // NOTE: must come after ALL hooks (Rules of Hooks). if (isUserLoading) { return ( diff --git a/packages/web/src/routes/api/chat/events.ts b/packages/web/src/routes/api/chat/events.ts deleted file mode 100644 index f14960b..0000000 --- a/packages/web/src/routes/api/chat/events.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { chatEventsShapeRequest } from "./shape"; - -export const Route = createFileRoute("/api/chat/events")({ - server: { handlers: { GET: ({ request }: { request: Request }) => chatEventsShapeRequest(request) } }, -}); diff --git a/packages/web/src/routes/api/chat/sessions.ts b/packages/web/src/routes/api/chat/sessions.ts deleted file mode 100644 index 8e8ae43..0000000 --- a/packages/web/src/routes/api/chat/sessions.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { auth } from "@/lib/auth"; -import { chatShapeRequest, quoteSqlLiteral } from "./shape"; - -export const Route = createFileRoute("/api/chat/sessions")({ - server: { - handlers: { - GET: async ({ request }: { request: Request }) => { - const session = await auth.api.getSession({ headers: request.headers }); - return session - ? chatShapeRequest( - request, - "chat_sessions", - `user_id = ${quoteSqlLiteral(session.user.id)}`, - ) - : new Response("Unauthorized", { status: 401 }); - }, - }, - }, -}); diff --git a/packages/web/src/routes/api/chat/shape.ts b/packages/web/src/routes/api/chat/shape.ts deleted file mode 100644 index f2f1cb9..0000000 --- a/packages/web/src/routes/api/chat/shape.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { auth } from "@/lib/auth"; -import { - buildElectricUpstreamUrl, - proxyToElectric, -} from "@/lib/electric-proxy"; - -export const quoteSqlLiteral = (value: string): string => - `'${value.replaceAll("'", "''")}'`; - -export async function chatShapeRequest( - request: Request, - table: string, - where: string, -): Promise { - const session = await auth.api.getSession({ headers: request.headers }); - if (!session) return new Response("Unauthorized", { status: 401 }); - return proxyToElectric(await buildElectricUpstreamUrl(request, table, where)); -} - -export async function chatEventsShapeRequest( - request: Request, -): Promise { - const session = await auth.api.getSession({ headers: request.headers }); - if (!session) return new Response("Unauthorized", { status: 401 }); - const sessionId = new URL(request.url).searchParams.get("sessionId"); - if (!sessionId) return new Response("sessionId is required", { status: 400 }); - return chatShapeRequest( - request, - "chat_run_events", - `session_id = ${quoteSqlLiteral(sessionId)} AND user_id = ${quoteSqlLiteral(session.user.id)}`, - ); -} diff --git a/packages/web/src/routes/api/users.ts b/packages/web/src/routes/api/users.ts deleted file mode 100644 index b35ebec..0000000 --- a/packages/web/src/routes/api/users.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { auth } from "@/lib/auth"; -import { - buildElectricUpstreamUrl, - proxyToElectric, -} from "@/lib/electric-proxy"; - -/** - * Users shape proxy route - * - * This route proxies requests to Electric Cloud with auth-aware filtering: - * 1. Validates the user's session via better-auth - * 2. Only allows users to sync their own profile data - * 3. Sets server-side shape parameters for security - * - * Client usage: - * ```ts - * const usersCollection = createCollection( - * electricCollectionOptions({ - * shapeOptions: { - * url: '/api/users', - * }, - * getKey: (item) => item.id, - * }) - * ) - * ``` - */ -export const Route = createFileRoute("/api/users")({ - server: { - handlers: { - GET: async ({ request }: { request: Request }) => { - try { - // Validate session via better-auth - const session = await auth.api.getSession({ - headers: request.headers, - }); - - console.log("[api/users] Session:", session ? "found" : "not found"); - - // Return 401 if not authenticated - if (!session) { - return new Response("Unauthorized", { status: 401 }); - } - - // Build Electric URL with user-filtered where clause - // Users can only see their own profile data - const whereClause = `id = '${session.user.id}'`; - const originUrl = await buildElectricUpstreamUrl( - request, - "user", - whereClause, - ); - - console.log("[api/users] Proxying to:", originUrl.toString()); - - // Proxy to Electric and return response - return proxyToElectric(originUrl); - } catch (error) { - console.error("[api/users] Error:", error); - return new Response( - JSON.stringify({ - error: "Internal server error", - message: error instanceof Error ? error.message : "Unknown error", - }), - { - status: 500, - headers: { "Content-Type": "application/json" }, - }, - ); - } - }, - }, - }, -}); diff --git a/scripts/agent-login.sh b/scripts/agent-login.sh index 361c91f..4b07406 100755 --- a/scripts/agent-login.sh +++ b/scripts/agent-login.sh @@ -32,8 +32,8 @@ # human step is needed as long as the email below is allowed to sign in. # # Usage: -# ./scripts/agent-login.sh # OTP mode, localhost (via Caddy) -# ./scripts/agent-login.sh http://localhost:3000/app # OTP mode, direct (no Caddy) +# ./scripts/agent-login.sh # OTP mode, localhost +# ./scripts/agent-login.sh http://localhost:3000/app # OTP mode, explicit URL # ./scripts/agent-login.sh --bot # bot-login against a preview # AGENT_EMAIL=agent@structa.dev ./scripts/agent-login.sh # AGENT_PROFILE=~/.structa-agent ./scripts/agent-login.sh @@ -50,7 +50,7 @@ AGENT_PROFILE="${AGENT_PROFILE:-$HOME/.structa-agent}" usage() { cat >&2 <<'EOF' Usage: - ./scripts/agent-login.sh [target-url] OTP login (default: https://localhost:3010/app via Caddy) + ./scripts/agent-login.sh [target-url] OTP login (default: http://localhost:3000/app) ./scripts/agent-login.sh --bot bot-login against a PR preview (no OTP / DB) Environment: @@ -85,8 +85,8 @@ while [[ $# -gt 0 ]]; do esac done -TARGET="${1:-https://localhost:3010/app}" -WEB_BASE="${WEB_BASE:-https://localhost:3010}" +TARGET="${1:-http://localhost:3000/app}" +WEB_BASE="${WEB_BASE:-http://localhost:3000}" # agent-browser command prefix — pass --profile on every invocation so the # persistent profile (cookies + localStorage) is reused across runs. diff --git a/scripts/start-caddy.sh b/scripts/start-caddy.sh deleted file mode 100755 index 9e52086..0000000 --- a/scripts/start-caddy.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env bash -# Start Caddy as an HTTP/2 reverse proxy for local Electric SQL development. -# Electric's long-polling shapes benefit from HTTP/2 multiplexing; Vite's -# dev server only supports HTTP/1.1 (6 concurrent connections per domain). -# Access the app at https://localhost:3010 instead of http://localhost:3000. -set -euo pipefail - -if ! command -v caddy &>/dev/null; then - echo "Caddy is not installed." - echo " brew install caddy" - echo " caddy trust" - exit 1 -fi - -exec caddy reverse-proxy \ - --from localhost:3010 \ - --to localhost:3000 \ - --internal-certs diff --git a/sst.config.ts b/sst.config.ts index 2b3ae7c..984e581 100644 --- a/sst.config.ts +++ b/sst.config.ts @@ -34,11 +34,9 @@ export default $config({ const web = await import('./infra/web'); const api = await import('./infra/api'); await import('./infra/database'); - await import('./infra/sync'); await import('./infra/storage'); const cloudfront = await import('./infra/cloudfront'); await import('./infra/email'); - await import('./infra/dev'); const cloudflare = await import('./infra/cloudflare'); await import('./infra/sns'); return {