Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
a6422ef
refactor: move models defaults to shared
raivieiraadriano92 Jul 2, 2026
3773a5a
feat: version and snapshot-lock models defaults
raivieiraadriano92 Jul 2, 2026
f8ffa07
fix: version-gate models reconcile against downgrades
raivieiraadriano92 Jul 2, 2026
bc5d026
test: cover THU-637 downgrade regression
raivieiraadriano92 Jul 2, 2026
d9bb8e6
feat(backend): serve models defaults via /config
raivieiraadriano92 Jul 2, 2026
e36ce58
feat: prefer server models defaults when newer
raivieiraadriano92 Jul 2, 2026
6534157
fix: inline version upsert to avoid nested transaction
raivieiraadriano92 Jul 2, 2026
876ad4e
fix(backend): keep pglite in-memory when DATABASE_URL is a connection…
raivieiraadriano92 Jul 2, 2026
11dfdda
fix(tauri): allow localhost:8080 in CSP connect-src
raivieiraadriano92 Jul 2, 2026
1b4d4ad
fix: address bugbot findings — empty OTA guard, version-key upsert, p…
raivieiraadriano92 Jul 2, 2026
da9f006
fix: harden model reconcile against sync races
raivieiraadriano92 Jul 6, 2026
63dfaa3
chore: harden shared/ boundary — extract hashValues, drop apiKey, enf…
raivieiraadriano92 Jul 6, 2026
4a277a1
refactor: options object for reconcileDefaultsForTable
raivieiraadriano92 Jul 6, 2026
e28fefd
fix(ci): pick up shared subdirs in test globs, camelCase constants
raivieiraadriano92 Jul 6, 2026
b8f0746
chore: cache short-circuit, stricter picker guard, pglite fallback warn
raivieiraadriano92 Jul 6, 2026
cb7b426
fix: address bugbot round 2 — marker advance, sync guard, profile ins…
raivieiraadriano92 Jul 6, 2026
7675cdd
feat: land THU-645 model lineup update + resurrect race guard
raivieiraadriano92 Jul 6, 2026
a8bf8dc
fix: gate resurrect and profile-cleanup on sync completion
raivieiraadriano92 Jul 6, 2026
4bcb402
fix: filter OTA models without a bundled profile from reconcile
raivieiraadriano92 Jul 6, 2026
107513d
fix: reject disjoint OTA payloads in pickModelsDefaults
raivieiraadriano92 Jul 6, 2026
d97d297
fix(THU-637): address open PR review blockers
raivieiraadriano92 Jul 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,12 @@ See [docs/architecture/powersync-account-devices.md](docs/architecture/powersync

**Custom SharedWorker and `@powersync/web` internal path:** `vite.config.ts` defines a `powersync-web-internal` alias pointing to `@powersync/web/lib/src` (an internal, non-public-API path). This is required for the custom `ThunderboltSharedSyncImplementation` to extend `SharedSyncImplementation`. When upgrading `@powersync/web`, verify this internal path still exists — it may break without a TypeScript error.

## Reconciled defaults and version bumps

Reconciled default tables (`shared/defaults/models.ts` today, more to follow) ship a monotonic `defaults<X>Version` constant next to the defaults array. Reconciliation uses it as the ordering signal so multi-device sync groups converge without ping-ponging (see THU-637): a device only overwrites an existing row when its defaults version is strictly newer than the highest ever applied on this account.

**When you change any default in one of these files, bump the version constant.** A colocated snapshot test (e.g. `shared/defaults/models.test.ts`) fails on any content change without a matching version bump and tells you exactly what to update.

## CORS and API headers

Both the main API (`backend/src/index.ts`) and the PostHog proxy route (`backend/src/posthog/routes.ts`) use `cors({ allowedHeaders: true })`, which echoes back whatever the browser requests in `Access-Control-Request-Headers`. This is required by the universal proxy at `/v1/proxy`, which forwards arbitrary upstream headers as `X-Proxy-Passthrough-*` (LLM SDKs add `x-api-key`, `x-stainless-*`, `openai-organization`, etc. — a static allowlist would break preflight whenever a new provider header appears). Adding a new custom header to any request requires no CORS-config change.
Expand Down
7 changes: 7 additions & 0 deletions backend/src/api/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import { describe, expect, it } from 'bun:test'
import { Elysia } from 'elysia'
import { createTestSettings } from '@/test-utils/settings'
import { defaultModels, defaultModelsVersion } from '@shared/defaults/models'
import { createConfigRoutes } from './config'

const fetchConfig = async (settings: Parameters<typeof createConfigRoutes>[0]) => {
Expand Down Expand Up @@ -53,5 +54,11 @@ describe('Config Routes', () => {
const { status } = await fetchConfig(createTestSettings())
expect(status).toBe(200)
})

it('ships models defaults with their shared version', async () => {
const { body } = await fetchConfig(createTestSettings())
expect(body.defaults.models.version).toBe(defaultModelsVersion)
expect(body.defaults.models.data).toEqual(defaultModels)
})
})
})
12 changes: 12 additions & 0 deletions backend/src/api/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,18 @@

import type { Settings } from '@/config/settings'
import { safeErrorHandler } from '@/middleware/error-handling'
import { defaultModels, defaultModelsVersion } from '@shared/defaults/models'
import { Elysia } from 'elysia'

/**
* Public app config — the single source of deployment-level UI capability flags
* (no auth, fetched at boot). The frontend mirrors this into its config store and
* falls back to the cached value when offline (standalone mode keeps working).
*
* `defaults` ships the reconciled default sets (models today, more to follow) as
* an OTA channel: clients pick between the server payload and their bundled copy
* by comparing versions, so shipped defaults changes don't require a client
* release. See "Reconciled defaults and version bumps" in AGENTS.md.
*/
export const createConfigRoutes = (settings: Settings) =>
new Elysia({ prefix: '/config' }).onError(safeErrorHandler).get('/', () => ({
Expand All @@ -20,4 +26,10 @@ export const createConfigRoutes = (settings: Settings) =>
allowCustomAgents: settings.allowCustomAgents,
// Omit when unset so the frontend treats it as "no enforcement" without parsing an empty string as semver.
minAppVersion: settings.minAppVersion || undefined,
defaults: {
Comment thread
raivieiraadriano92 marked this conversation as resolved.
models: {
version: defaultModelsVersion,
data: defaultModels,
},
},
}))
50 changes: 47 additions & 3 deletions backend/src/db/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,55 @@ const postgresUrl = isPglite
? null
: process.env.DATABASE_URL || (isDevelopment ? 'postgresql://postgres:postgres@localhost:5433/postgres' : '')

if (isPglite && process.env.DATABASE_URL) {
mkdirSync(resolve(process.env.DATABASE_URL), { recursive: true })
// When DRIVER=pglite, `DATABASE_URL` is treated as a *data-directory path*
// (`.env.example` documents `.pglite/data`). The default dev / e2e `.env`
// ships `postgresql://...` for the postgres driver, though, and inherits
// into pglite-mode runs (bun test, playwright web-server, manual `bun run
// src/index.ts` with mixed env). `new PGlite('postgresql://...')` then
// treats the connection string as a path and bootstraps a real Postgres
// data dir into `backend/postgresql:/postgres:postgres@localhost:.../...`.
// Detect the schema and treat connection-string values as "no path given"
// (i.e. in-memory PGlite).
const isPostgresConnectionUrl = (url: string | undefined): boolean =>
typeof url === 'string' && /^(?:postgres|postgresql):\/\//.test(url)

const pgliteDataDir =
isPglite && process.env.DATABASE_URL && !isPostgresConnectionUrl(process.env.DATABASE_URL)
Comment thread
raivieiraadriano92 marked this conversation as resolved.
? process.env.DATABASE_URL
: undefined

/**
* Return `scheme://host[:port]` from a DB connection string, dropping any
* embedded userinfo. Falls back to `<unparseable>` so we never leak the raw
* value on a malformed URL — connection strings can carry credentials
* (`postgres://user:password@host/db`) and logs travel further than we expect.
*/
const redactDatabaseUrl = (url: string): string => {
try {
const parsed = new URL(url)
return `${parsed.protocol}//${parsed.host}`
} catch {
return '<unparseable>'
}
}

const pgliteClient = isPglite ? new PGlite(process.env.DATABASE_URL) : null // undefined = in-memory
if (isPglite && process.env.DATABASE_URL && isPostgresConnectionUrl(process.env.DATABASE_URL)) {
console.warn(
`[db] DATABASE_DRIVER=pglite but DATABASE_URL is a postgres connection string ` +
`(${redactDatabaseUrl(process.env.DATABASE_URL)}) — falling back to in-memory PGlite. ` +
`Set DATABASE_URL to a directory path (e.g. .pglite/data) if you meant to persist.`,
)
}

if (pgliteDataDir) {
mkdirSync(resolve(pgliteDataDir), { recursive: true })
}

const pgliteClient = isPglite
? pgliteDataDir
? new PGlite(pgliteDataDir)
: new PGlite() // no dataDir → in-memory
: null

const pgliteDb = pgliteClient ? drizzlePglite({ client: pgliteClient, schema }) : null

Expand Down
2 changes: 1 addition & 1 deletion backend/src/inference/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ describe('Inference Routes', () => {
})

it('should validate all supported models', () => {
const expectedModels = ['mistral-medium-3.1', 'mistral-large-3', 'sonnet-4.5', 'opus-4.8']
const expectedModels = ['mistral-medium-3.1', 'mistral-large-3', 'sonnet-4.5', 'opus-4.8', 'deepseek-v4-flash']
expect(Object.keys(supportedModels)).toEqual(expectedModels)
})

Expand Down
4 changes: 4 additions & 0 deletions backend/src/inference/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ export const supportedModels: Record<string, ModelConfig> = {
internalName: 'claude-opus-4-8',
omitTemperature: true,
},
'deepseek-v4-flash': {
provider: 'fireworks',
internalName: 'accounts/fireworks/models/deepseek-v4-flash',
},
}

/**
Expand Down
2 changes: 2 additions & 0 deletions backend/src/test-utils/test-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import { closeSharedIsolatedTestDb, testDbManager } from './db'
// pglite to postgres; preload the test driver explicitly so db/client.ts doesn't
// throw at module load when DATABASE_URL is not set (e.g. swagger.test.ts and
// other tests that transitively import createApp from @/index).
// db/client.ts itself now guards against `DATABASE_URL=postgresql://...` when
// DRIVER=pglite (falls back to in-memory), so no need to `delete` it here.
process.env.DATABASE_DRIVER = 'pglite'

// Disable rate limiting in tests: RateLimiterDrizzle uses its own internal
Expand Down
2 changes: 1 addition & 1 deletion backend/src/tinfoil/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ describe('createTinfoilRoutes', () => {

it('forwards JSON bodies untouched (parse: none keeps the stream intact)', async () => {
const app = buildApp()
const jsonBody = JSON.stringify({ model: 'deepseek-v4-pro', messages: [] })
const jsonBody = JSON.stringify({ model: 'glm-5-2', messages: [] })

await drain(
await app.handle(
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
"type": "module",
"license": "MPL-2.0",
"scripts": {
"test": "bun test --cwd=src --timeout 5000 --randomize && bun test shared/*.test.ts scripts/create-release.test.ts ./.github/scripts/post-pr-metrics.test.js ./.github/scripts/review-orchestrator.test.mjs --timeout 5000 --randomize",
"test:5x": "bun test --cwd=src --timeout 3600000 --randomize --rerun-each 5 && bun test shared/*.test.ts scripts/create-release.test.ts ./.github/scripts/post-pr-metrics.test.js ./.github/scripts/review-orchestrator.test.mjs --timeout 3600000 --randomize --rerun-each 5",
"test": "bun test --cwd=src --timeout 5000 --randomize && bun test shared/ scripts/create-release.test.ts ./.github/scripts/post-pr-metrics.test.js ./.github/scripts/review-orchestrator.test.mjs --timeout 5000 --randomize",
"test:5x": "bun test --cwd=src --timeout 3600000 --randomize --rerun-each 5 && bun test shared/ scripts/create-release.test.ts ./.github/scripts/post-pr-metrics.test.js ./.github/scripts/review-orchestrator.test.mjs --timeout 3600000 --randomize --rerun-each 5",
"test:watch": "bun test --cwd=src --watch",
"test:backend": "cd backend && bun test --timeout 5000 --randomize",
"test:backend:5x": "cd backend && bun test --timeout 5000 --randomize --rerun-each 5",
Expand Down
35 changes: 35 additions & 0 deletions shared/defaults/models.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import { describe, expect, test } from 'bun:test'
import { defaultModels, defaultModelsVersion, hashModel } from './models'

/**
* Snapshot pinning the shipped defaults to their declared version. When you
* change any default model (add/remove/edit/reorder), this test fails.
*
* Fix it in this order:
* 1. Bump `defaultModelsVersion` in `shared/defaults/models.ts`.
* 2. Update `expected` below to match the actual values from the failure.
*
* The version is the ordering signal reconcile uses to decide who owns the
* newest defaults across devices (THU-637). Changing defaults without bumping
* the version breaks that ordering silently.
*/
const computeSnapshotHash = () =>
defaultModels.map((model, index) => `${index}:${model.id}:${hashModel(model)}`).join('|')

const expected = {
version: 2,
hash: '0:019af08a-c27b-7074-8aac-95315d1ef3fd:-1vf2pk|1:019f227e-d640-727d-ba12-d51bd7d0a3d6:bvaax2|2:019e7580-2b0e-719c-a43f-d2b56e7f31b4:-g7x2jr',
}

describe('defaultModels version snapshot', () => {
Comment thread
raivieiraadriano92 marked this conversation as resolved.
test('version and content are in sync — read the file header if this fails', () => {
expect({
version: defaultModelsVersion,
hash: computeSnapshotHash(),
}).toEqual(expected)
})
})
170 changes: 170 additions & 0 deletions shared/defaults/models.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import { hashValues } from '@shared/lib/hash'

/**
* Shape of a shipped model default. Structurally a subset of the frontend
* `Model` type (`src/types.ts`) — `SharedModel` intentionally omits `apiKey`,
* which is a runtime concern (populated by the DAL from a LEFT JOIN with the
* local-only `models_secrets` table) and must never traverse the wire. That
* omission also makes the public `/config` endpoint structurally incapable of
* leaking an API key even if a future server-shipped payload got sloppy.
*
* A compile-time assignability check in `src/types.ts` guards against silent
* drift when the frontend `Model` gains new required fields.
*/
export type SharedModel = {
Comment thread
raivieiraadriano92 marked this conversation as resolved.
id: string
provider: 'openai' | 'custom' | 'openrouter' | 'thunderbolt' | 'anthropic' | 'tinfoil'
name: string
model: string
url: string | null
isSystem: number | null
enabled: number
toolUsage: number
isConfidential: number
startWithReasoning: number
supportsParallelToolCalls: number
contextWindow: number | null
deletedAt: string | null
defaultHash: string | null
vendor: string | null
description: string | null
userId: string | null
}

/**
* Compute hash of user-editable fields for a model.
* Includes deletedAt to treat soft-delete as a user configuration choice.
*/
export const hashModel = (model: SharedModel): string => {
return hashValues([
model.name,
model.provider,
model.model,
model.url,
model.isSystem,
model.enabled,
model.toolUsage,
model.isConfidential,
model.startWithReasoning,
model.supportsParallelToolCalls,
model.contextWindow,
model.deletedAt,
])
}

/**
* Default system models shipped with the application.
* These are upserted on app start and serve as the baseline for diff comparisons.
* Each model is exported individually so it can be referenced by automations.
*/

/**
* Opus 4.8 reuses the row id originally assigned to Sonnet 4.5 (and inherited by 4.7).
* Reconciliation upgrades unmodified rows in place; edited rows survive.
*/
export const defaultModelOpus48: SharedModel = {
id: '019af08a-c27b-7074-8aac-95315d1ef3fd',
name: 'Opus 4.8',
provider: 'thunderbolt',
model: 'opus-4.8',
isSystem: 1,
enabled: 1,
isConfidential: 0,
contextWindow: 200000,
toolUsage: 1,
startWithReasoning: 0,
supportsParallelToolCalls: 1,
deletedAt: null,
url: null,
defaultHash: null,
vendor: 'anthropic',
description: 'Top-tier Anthropic reasoning',
userId: null,
}

/**
* Flash ships under a fresh id — not the retired V4 Pro id. Reusing Pro's id
* would flip `isConfidential` 1 → 0 on threads that were created encrypted
* (`isEncrypted` mirrors the model's `isConfidential` at creation), stranding
* them because the model picker and send guard both enforce
* `isEncrypted === isConfidential`. The retired Pro row is instead
* soft-deleted by `cleanupRemovedDefaults`, so encrypted threads bound to it
Comment thread
raivieiraadriano92 marked this conversation as resolved.
* surface as "model retired" rather than broken chats.
*
* The reconciler's `frozenFields: ['isConfidential', 'provider']` guard
* enforces the same invariant from the OTA side — an OTA payload that ships
* an existing id with `isConfidential` flipped is silently ignored on those
* two columns. New values for either field must ship under a fresh id.
*/
export const defaultModelDeepseekV4Flash: SharedModel = {
id: '019f227e-d640-727d-ba12-d51bd7d0a3d6',
name: 'DeepSeek V4 Flash',
provider: 'thunderbolt',
model: 'deepseek-v4-flash',
isSystem: 1,
enabled: 1,
isConfidential: 0,
contextWindow: 131072,
toolUsage: 1,
startWithReasoning: 0,
supportsParallelToolCalls: 0,
deletedAt: null,
url: null,
defaultHash: null,
vendor: 'deepseek',
description: 'Fast DeepSeek reasoning',
userId: null,
}

export const defaultModelGlm52: SharedModel = {
id: '019e7580-2b0e-719c-a43f-d2b56e7f31b4',
name: 'GLM 5.2',
provider: 'tinfoil',
model: 'glm-5-2',
isSystem: 1,
enabled: 1,
isConfidential: 1,
contextWindow: 131072,
toolUsage: 1,
startWithReasoning: 0,
supportsParallelToolCalls: 0,
deletedAt: null,
url: null,
defaultHash: null,
vendor: 'zhipu',
description: 'Confidential chat via Tinfoil',
userId: null,
}

/**
* Array of all default models for iteration. Order = display order in the
* "Provided" group of the model picker. Reorder freely — but bump
* `defaultModelsVersion` when you do.
*
* Retired between V1 and V2: `defaultModelDeepseekV4Pro` (superseded by
* Flash under a fresh id) and `defaultModelKimiK26` (dropped). Their rows are
* soft-deleted by `cleanupRemovedDefaults` on next reconcile; unedited copies
* disappear cleanly, user-edited copies survive but point at retired ids and
* will surface upstream errors when used.
*/
export const defaultModels: ReadonlyArray<SharedModel> = [
defaultModelOpus48,
defaultModelDeepseekV4Flash,
defaultModelGlm52,
] as const

/**
* Monotonic version of the shipped defaults. Bump every time `defaultModels`
* changes in any way. The reconciler uses this as the ordering signal to
* decide which device's defaults win in a multi-device sync group (THU-637):
* a device only overwrites existing rows when its picked defaults version is
* strictly newer than the highest ever applied on this account.
*
* The paired snapshot test in `models.test.ts` fails on any change to this
* file's defaults without a matching version bump.
*/
export const defaultModelsVersion = 2
Loading
Loading