diff --git a/AGENTS.md b/AGENTS.md index d26710757..e63dda1ca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 `defaultsVersion` 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. diff --git a/backend/src/api/config.test.ts b/backend/src/api/config.test.ts index 5236636d0..ee2195710 100644 --- a/backend/src/api/config.test.ts +++ b/backend/src/api/config.test.ts @@ -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[0]) => { @@ -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) + }) }) }) diff --git a/backend/src/api/config.ts b/backend/src/api/config.ts index 45029b23b..8a05f6262 100644 --- a/backend/src/api/config.ts +++ b/backend/src/api/config.ts @@ -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('/', () => ({ @@ -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: { + models: { + version: defaultModelsVersion, + data: defaultModels, + }, + }, })) diff --git a/backend/src/db/client.ts b/backend/src/db/client.ts index 27d1f6789..d0cc82b14 100644 --- a/backend/src/db/client.ts +++ b/backend/src/db/client.ts @@ -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) + ? process.env.DATABASE_URL + : undefined + +/** + * Return `scheme://host[:port]` from a DB connection string, dropping any + * embedded userinfo. Falls back to `` 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 '' + } } -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 diff --git a/backend/src/inference/routes.test.ts b/backend/src/inference/routes.test.ts index cbb109b22..591215f3f 100644 --- a/backend/src/inference/routes.test.ts +++ b/backend/src/inference/routes.test.ts @@ -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) }) diff --git a/backend/src/inference/routes.ts b/backend/src/inference/routes.ts index 46b02910c..f0799b128 100644 --- a/backend/src/inference/routes.ts +++ b/backend/src/inference/routes.ts @@ -47,6 +47,10 @@ export const supportedModels: Record = { internalName: 'claude-opus-4-8', omitTemperature: true, }, + 'deepseek-v4-flash': { + provider: 'fireworks', + internalName: 'accounts/fireworks/models/deepseek-v4-flash', + }, } /** diff --git a/backend/src/test-utils/test-setup.ts b/backend/src/test-utils/test-setup.ts index 24a747fd2..4b75ad97a 100644 --- a/backend/src/test-utils/test-setup.ts +++ b/backend/src/test-utils/test-setup.ts @@ -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 diff --git a/backend/src/tinfoil/routes.test.ts b/backend/src/tinfoil/routes.test.ts index 4eb12ff51..8a4ae255a 100644 --- a/backend/src/tinfoil/routes.test.ts +++ b/backend/src/tinfoil/routes.test.ts @@ -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( diff --git a/package.json b/package.json index 4f72d4d71..bc52c4424 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/shared/defaults/models.test.ts b/shared/defaults/models.test.ts new file mode 100644 index 000000000..484e13eaa --- /dev/null +++ b/shared/defaults/models.test.ts @@ -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', () => { + test('version and content are in sync — read the file header if this fails', () => { + expect({ + version: defaultModelsVersion, + hash: computeSnapshotHash(), + }).toEqual(expected) + }) +}) diff --git a/shared/defaults/models.ts b/shared/defaults/models.ts new file mode 100644 index 000000000..d8fb580cc --- /dev/null +++ b/shared/defaults/models.ts @@ -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 = { + 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 + * 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 = [ + 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 diff --git a/shared/lib/hash.ts b/shared/lib/hash.ts new file mode 100644 index 000000000..5402c5571 --- /dev/null +++ b/shared/lib/hash.ts @@ -0,0 +1,25 @@ +/* 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/. */ + +/** + * Compute a simple 32-bit hash from an array of values. Used to fingerprint + * default definitions so reconciliation can detect user edits by comparing + * the stored `defaultHash` against a fresh recomputation. + * + * This function is load-bearing across the frontend/backend/shared boundary: + * the exact same output is expected on every side, because stored hashes in + * user databases depend on it byte-for-byte. Any change here silently + * invalidates every existing `defaultHash` in the wild — treat as a wire + * contract, not an implementation detail. + */ +export const hashValues = (values: (string | number | null | undefined)[]): string => { + const str = values.join('|') + let hash = 0 + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i) + hash = (hash << 5) - hash + char + hash = hash & hash + } + return hash.toString(36) +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index aae8f95f4..3c67f9fc8 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -23,7 +23,7 @@ "security": { "csp": { "default-src": "'self' tauri: asset:", - "connect-src": "'self' ipc: http://ipc.localhost https: wss: ws://localhost:8000 http://localhost:8000 http://localhost:11434", + "connect-src": "'self' ipc: http://ipc.localhost https: wss: ws://localhost:8000 http://localhost:8000 ws://localhost:8080 http://localhost:8080 http://localhost:11434", "img-src": "'self' asset: data: https://api.thunderbolt.io", "font-src": "'self' data:", "style-src": "'self' 'unsafe-inline'", diff --git a/src/ai/eval/debug-single.ts b/src/ai/eval/debug-single.ts index 8ed981b61..3fa41c4a0 100644 --- a/src/ai/eval/debug-single.ts +++ b/src/ai/eval/debug-single.ts @@ -9,7 +9,7 @@ import { aiFetchStreamingResponse } from '@/ai/fetch' import { setupTestDatabase, teardownTestDatabase } from '@/dal/test-utils' import { getLocalSetting } from '@/stores/local-settings-store' -import { defaultModelOpus48 } from '@/defaults/models' +import { defaultModelOpus48 } from '@shared/defaults/models' import { defaultModeChat } from '@/defaults/modes' import { isSsoMode } from '@/lib/auth-mode' import { getAuthToken } from '@/lib/auth-token' diff --git a/src/ai/eval/scenarios.ts b/src/ai/eval/scenarios.ts index f921a6e98..f77ab7bda 100644 --- a/src/ai/eval/scenarios.ts +++ b/src/ai/eval/scenarios.ts @@ -2,7 +2,7 @@ * 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 { defaultModelOpus48 } from '@/defaults/models' +import { defaultModelOpus48 } from '@shared/defaults/models' import type { EvalCriteria, EvalScenario } from './types' const models = [{ name: 'opus', id: defaultModelOpus48.id }] as const diff --git a/src/api/config-store.ts b/src/api/config-store.ts index 7597391e4..e29ce1e0b 100644 --- a/src/api/config-store.ts +++ b/src/api/config-store.ts @@ -2,6 +2,7 @@ * 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 type { SharedModel } from '@shared/defaults/models' import { create } from 'zustand' import { persist } from 'zustand/middleware' @@ -15,6 +16,15 @@ export type AppConfig = { /** Minimum semver string the server allows. Clients below this are hard-blocked * until they upgrade. Absent/empty = no enforcement. */ minAppVersion?: string + /** Server-shipped default sets, versioned so the client can pick between + * server and bundled by whichever declares the higher version. See + * "Reconciled defaults and version bumps" in AGENTS.md. */ + defaults?: { + models?: { + version: number + data: SharedModel[] + } + } } type ConfigStore = { diff --git a/src/components/ui/model-selector/model-selector.test.ts b/src/components/ui/model-selector/model-selector.test.ts index 057d81b29..984988cde 100644 --- a/src/components/ui/model-selector/model-selector.test.ts +++ b/src/components/ui/model-selector/model-selector.test.ts @@ -102,7 +102,7 @@ describe('needsApiKey', () => { test('system tinfoil rows do not need a key (injected by backend proxy)', () => { const model = makeModel({ id: 'tinfoil-system', - name: 'DeepSeek V4 Pro', + name: 'GLM 5.2', provider: 'tinfoil', isSystem: 1, apiKey: null, diff --git a/src/dal/model-profiles.test.ts b/src/dal/model-profiles.test.ts index 1167b79e8..a7fc26010 100644 --- a/src/dal/model-profiles.test.ts +++ b/src/dal/model-profiles.test.ts @@ -4,7 +4,7 @@ import { getDb } from '@/db/database' import { modelProfilesTable, modelsTable } from '@/db/tables' -import { defaultModelOpus48 } from '@/defaults/models' +import { defaultModelOpus48 } from '@shared/defaults/models' import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'bun:test' import { eq } from 'drizzle-orm' import { v7 as uuidv7 } from 'uuid' diff --git a/src/dal/models.test.ts b/src/dal/models.test.ts index 172951ec8..3cdf9e06e 100644 --- a/src/dal/models.test.ts +++ b/src/dal/models.test.ts @@ -15,7 +15,7 @@ import { import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'bun:test' import { eq } from 'drizzle-orm' import { v7 as uuidv7 } from 'uuid' -import { defaultModelOpus48, hashModel } from '@/defaults/models' +import { defaultModelOpus48, hashModel } from '@shared/defaults/models' import { isModelModified } from '@/defaults/utils' import type { Model } from '@/types' import { diff --git a/src/dal/models.ts b/src/dal/models.ts index 6c5b19e8e..a18df6844 100644 --- a/src/dal/models.ts +++ b/src/dal/models.ts @@ -5,7 +5,7 @@ import { and, desc, eq, getTableColumns, isNotNull, isNull, or, sql } from 'drizzle-orm' import type { AnyDrizzleDatabase } from '../db/database-interface' import { modelsSecretsTable, modelsTable, settingsTable } from '../db/tables' -import { hashModel } from '../defaults/models' +import { hashModel, type SharedModel } from '@shared/defaults/models' import { clearNullableColumns, nowIso } from '../lib/utils' import type { DrizzleQueryWithPromise, Model } from '@/types' import { getLastMessage } from './chat-messages' @@ -164,8 +164,12 @@ export const updateModel = async (db: AnyDrizzleDatabase, id: string, updates: P * default template so we never overwrite the row's real owner with `null` * (which would surface as an empty PATCH and a 400 from the upload handler). */ -export const resetModelToDefault = async (db: AnyDrizzleDatabase, id: string, defaultModel: Model): Promise => { - const { defaultHash, apiKey, userId, ...defaultFields } = defaultModel +export const resetModelToDefault = async ( + db: AnyDrizzleDatabase, + id: string, + defaultModel: SharedModel, +): Promise => { + const { defaultHash, userId, ...defaultFields } = defaultModel await db.transaction(async (tx) => { await tx .update(modelsTable) diff --git a/src/dal/prompts.test.ts b/src/dal/prompts.test.ts index 97e909bdc..05575b072 100644 --- a/src/dal/prompts.test.ts +++ b/src/dal/prompts.test.ts @@ -8,7 +8,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'bun:test' import { eq } from 'drizzle-orm' import { v7 as uuidv7 } from 'uuid' import { defaultAutomations, hashPrompt } from '../defaults/automations' -import { defaultModels, hashModel } from '../defaults/models' +import { defaultModels, hashModel } from '@shared/defaults/models' import { reconcileDefaultsForTable } from '../lib/reconcile-defaults' import { nowIso } from '@/lib/utils' import { createAutomation, getAllPrompts, getTriggerPromptForThread, resetAutomationToDefault } from './prompts' diff --git a/src/defaults/automations.ts b/src/defaults/automations.ts index d5b546ad4..8859a880f 100644 --- a/src/defaults/automations.ts +++ b/src/defaults/automations.ts @@ -4,7 +4,7 @@ import { hashValues } from '@/lib/utils' import type { Prompt } from '@/types' -import { defaultModelOpus48 } from './models' +import { defaultModelOpus48 } from '@shared/defaults/models' /** * Compute hash of user-editable fields for a prompt diff --git a/src/defaults/index.ts b/src/defaults/index.ts index 0ab28783a..33b376846 100644 --- a/src/defaults/index.ts +++ b/src/defaults/index.ts @@ -9,4 +9,4 @@ export { hashPrompt, } from './automations' export { defaultModes, hashMode } from './modes' -export { defaultModels, hashModel } from './models' +export { defaultModels, hashModel } from '@shared/defaults/models' diff --git a/src/defaults/model-profiles.test.ts b/src/defaults/model-profiles.test.ts index 68c279d8f..a52095918 100644 --- a/src/defaults/model-profiles.test.ts +++ b/src/defaults/model-profiles.test.ts @@ -4,6 +4,7 @@ import { describe, expect, test } from 'bun:test' import type { ModelProfile } from '@/types' +import { defaultModels } from '@shared/defaults/models' import { defaultModelProfiles, hashModelProfile } from './model-profiles' const createStubProfile = (overrides: Partial = {}): ModelProfile => ({ @@ -77,13 +78,18 @@ describe('hashModelProfile', () => { }) describe('defaultModelProfiles', () => { - test('contains four profiles', () => { - expect(defaultModelProfiles).toHaveLength(4) - }) - test('each profile has a non-null modelId', () => { for (const profile of defaultModelProfiles) { expect(profile.modelId).toBeTruthy() } }) + + test('profile set pairs 1:1 with the default model set', () => { + // Catches wiring bugs — a profile pointing at a removed/renamed model, or a + // model shipped without its profile — without needing a manual count bump + // every time the lineup changes. + const modelIds = new Set(defaultModels.map((m) => m.id)) + const profileModelIds = new Set(defaultModelProfiles.map((p) => p.modelId)) + expect(profileModelIds).toEqual(modelIds) + }) }) diff --git a/src/defaults/model-profiles.ts b/src/defaults/model-profiles.ts index ecca0434f..f96a286e5 100644 --- a/src/defaults/model-profiles.ts +++ b/src/defaults/model-profiles.ts @@ -3,9 +3,8 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ export { - defaultModelProfileDeepseekV4Pro, + defaultModelProfileDeepseekV4Flash, defaultModelProfileGlm52, - defaultModelProfileKimiK26, defaultModelProfileOpus48, defaultModelProfiles, hashModelProfile, diff --git a/src/defaults/model-profiles/deepseek.ts b/src/defaults/model-profiles/deepseek.ts index 2bfcc1e31..483803577 100644 --- a/src/defaults/model-profiles/deepseek.ts +++ b/src/defaults/model-profiles/deepseek.ts @@ -3,10 +3,10 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import type { ModelProfile } from '@/types' -import { defaultModelDeepseekV4Pro } from '@/defaults/models' +import { defaultModelDeepseekV4Flash } from '@shared/defaults/models' -export const defaultModelProfileDeepseekV4Pro: ModelProfile = { - modelId: defaultModelDeepseekV4Pro.id, +export const defaultModelProfileDeepseekV4Flash: ModelProfile = { + modelId: defaultModelDeepseekV4Flash.id, temperature: 0.2, maxSteps: 20, maxAttempts: 2, diff --git a/src/defaults/model-profiles/glm.ts b/src/defaults/model-profiles/glm.ts index f668874f7..3c2443b0e 100644 --- a/src/defaults/model-profiles/glm.ts +++ b/src/defaults/model-profiles/glm.ts @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import type { ModelProfile } from '@/types' -import { defaultModelGlm52 } from '@/defaults/models' +import { defaultModelGlm52 } from '@shared/defaults/models' export const defaultModelProfileGlm52: ModelProfile = { modelId: defaultModelGlm52.id, diff --git a/src/defaults/model-profiles/index.ts b/src/defaults/model-profiles/index.ts index 5740d2f4e..d4e4e7a11 100644 --- a/src/defaults/model-profiles/index.ts +++ b/src/defaults/model-profiles/index.ts @@ -4,14 +4,12 @@ import { hashValues } from '@/lib/utils' import type { ModelProfile } from '@/types' -import { defaultModelProfileDeepseekV4Pro } from './deepseek' +import { defaultModelProfileDeepseekV4Flash } from './deepseek' import { defaultModelProfileGlm52 } from './glm' -import { defaultModelProfileKimiK26 } from './kimi' import { defaultModelProfileOpus48 } from './opus' -export { defaultModelProfileDeepseekV4Pro } from './deepseek' +export { defaultModelProfileDeepseekV4Flash } from './deepseek' export { defaultModelProfileGlm52 } from './glm' -export { defaultModelProfileKimiK26 } from './kimi' export { defaultModelProfileOpus48 } from './opus' /** @@ -46,7 +44,6 @@ export const hashModelProfile = (profile: ModelProfile): string => /** All default model profiles for iteration */ export const defaultModelProfiles: ReadonlyArray = [ defaultModelProfileOpus48, - defaultModelProfileDeepseekV4Pro, - defaultModelProfileKimiK26, + defaultModelProfileDeepseekV4Flash, defaultModelProfileGlm52, ] as const diff --git a/src/defaults/model-profiles/kimi.ts b/src/defaults/model-profiles/kimi.ts deleted file mode 100644 index 55bc4635c..000000000 --- a/src/defaults/model-profiles/kimi.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* 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 type { ModelProfile } from '@/types' -import { defaultModelKimiK26 } from '@/defaults/models' - -export const defaultModelProfileKimiK26: ModelProfile = { - modelId: defaultModelKimiK26.id, - temperature: 0.2, - maxSteps: 20, - maxAttempts: 2, - nudgeThreshold: 6, - useSystemMessageModeDeveloper: 0, - providerOptions: null, - toolsOverride: null, - linkPreviewsOverride: null, - chatModeAddendum: null, - searchModeAddendum: null, - researchModeAddendum: null, - citationReinforcementEnabled: 0, - citationReinforcementPrompt: null, - nudgeFinalStep: null, - nudgePreventive: null, - nudgeRetry: null, - nudgeSearchFinalStep: null, - nudgeSearchPreventive: null, - nudgeSearchRetry: null, - deletedAt: null, - defaultHash: null, - userId: null, -} diff --git a/src/defaults/model-profiles/opus.ts b/src/defaults/model-profiles/opus.ts index 7c87c4066..c9b2e1286 100644 --- a/src/defaults/model-profiles/opus.ts +++ b/src/defaults/model-profiles/opus.ts @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import type { ModelProfile } from '@/types' -import { defaultModelOpus48 } from '@/defaults/models' +import { defaultModelOpus48 } from '@shared/defaults/models' export const defaultModelProfileOpus48: ModelProfile = { modelId: defaultModelOpus48.id, diff --git a/src/defaults/models.ts b/src/defaults/models.ts deleted file mode 100644 index a667ca798..000000000 --- a/src/defaults/models.ts +++ /dev/null @@ -1,133 +0,0 @@ -/* 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 '@/lib/utils' -import type { Model } from '@/types' - -/** - * Compute hash of user-editable fields for a model - * Includes deletedAt to treat soft-delete as a user configuration choice - */ -export const hashModel = (model: Model): 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: Model = { - 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, - apiKey: null, - url: null, - defaultHash: null, - vendor: 'anthropic', - description: 'Top-tier Anthropic reasoning', - userId: null, -} - -export const defaultModelDeepseekV4Pro: Model = { - id: '019e70af-e5b2-76d0-9ede-f22d8265bb14', - name: 'DeepSeek V4 Pro', - provider: 'tinfoil', - model: 'deepseek-v4-pro', - isSystem: 1, - enabled: 1, - isConfidential: 1, - contextWindow: 131072, - toolUsage: 1, - startWithReasoning: 0, - supportsParallelToolCalls: 0, - deletedAt: null, - apiKey: null, - url: null, - defaultHash: null, - vendor: 'deepseek', - description: 'Confidential reasoning via Tinfoil', - userId: null, -} - -export const defaultModelKimiK26: Model = { - id: '019e7580-2b0c-77d6-8b99-16a99abe4591', - name: 'Kimi K2.6', - provider: 'tinfoil', - model: 'kimi-k2-6', - isSystem: 1, - enabled: 1, - isConfidential: 1, - contextWindow: 131072, - toolUsage: 1, - startWithReasoning: 0, - supportsParallelToolCalls: 0, - deletedAt: null, - apiKey: null, - url: null, - defaultHash: null, - vendor: 'moonshot', - description: 'Confidential chat via Tinfoil', - userId: null, -} - -export const defaultModelGlm52: Model = { - 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, - apiKey: 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. - */ -export const defaultModels: ReadonlyArray = [ - defaultModelOpus48, - defaultModelDeepseekV4Pro, - defaultModelKimiK26, - defaultModelGlm52, -] as const diff --git a/src/defaults/utils.ts b/src/defaults/utils.ts index 443144981..b7c6fa6c8 100644 --- a/src/defaults/utils.ts +++ b/src/defaults/utils.ts @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { hashPrompt } from './automations' -import { hashModel } from './models' +import { hashModel } from '@shared/defaults/models' import { hashSetting } from './settings' import type { Model, Prompt, Setting } from '@/types' diff --git a/src/hooks/use-app-initialization.ts b/src/hooks/use-app-initialization.ts index 618febebf..6b82670cb 100644 --- a/src/hooks/use-app-initialization.ts +++ b/src/hooks/use-app-initialization.ts @@ -3,6 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { fetchConfig } from '@/api/config' +import { useConfigStore } from '@/api/config-store' import type { HttpClient } from '@/contexts' import { getSettings } from '@/dal' import { getAuthToken } from '@/lib/auth-token' @@ -14,6 +15,7 @@ import { createAppDir, resetAppDir } from '@/lib/fs' import { isSsoMode } from '@/lib/auth-mode' import { createAuthenticatedClient } from '@/lib/http' import { beginInitRun, getInitTimingPayload, recordInitStep } from '@/lib/init-timing' +import { pickModelsDefaults } from '@/lib/pick-defaults' import { getDatabasePath, getDatabaseType, getPlatform, isIndexedDbAvailable } from '@/lib/platform' import { initPosthog, trackError, trackEvent } from '@/lib/posthog' import { runDataMigrations } from '@/lib/data-migrations' @@ -159,9 +161,24 @@ const executeInitializationSteps = async (httpClient?: HttpClient): Promise database.waitForInitialSync()) + // Step 3.5: Settle the /config fetch so reconcile can prefer server-shipped + // defaults when they declare a higher version than the bundle. Awaited + // unconditionally: (a) `step0_fetch_config` must land in the timing payload + // for every boot, not just first launch; (b) leaving the promise floating + // past this point risks an unhandled rejection on any environment where + // fetchConfig's error handling weakens. `fetchConfig` is bounded by its + // internal timeout and swallows errors — the persisted cache remains in + // the store until a successful fetch replaces it, so `pickModelsDefaults` + // still reads the same value it would on the cache-hit fast path. + await fetchConfigPromise + const modelsDefaults = pickModelsDefaults(useConfigStore.getState().config.defaults?.models) + const initialSyncCompleted = initialSyncOutcome === 'synced' || initialSyncOutcome === 'disabled' + // Step 4: Reconcile defaults try { - await time('step4_reconcile_defaults', () => reconcileDefaults(db)) + await time('step4_reconcile_defaults', () => + reconcileDefaults(db, { models: modelsDefaults, initialSyncCompleted }), + ) } catch (error) { console.error('Failed to reconcile default settings:', error) const reconcileError = createHandleError('RECONCILE_DEFAULTS_FAILED', 'Failed to reconcile default settings', error) @@ -214,10 +231,6 @@ const executeInitializationSteps = async (httpClient?: HttpClient): Promise { @@ -18,7 +18,6 @@ describe('defaults', () => { expect(model.model).toBeDefined() expect(model.deletedAt).toBeNull() expect(model.defaultHash).toBeNull() - expect(model.apiKey).toBeNull() expect(model.url).toBeNull() } }) @@ -71,14 +70,13 @@ describe('defaults-hash', () => { test('hashModel ignores order of object creation', () => { const model = defaultModels[0] // Create model with same values but different property order - const reorderedModel: Model = { + const reorderedModel: SharedModel = { contextWindow: model.contextWindow, name: model.name, enabled: model.enabled, provider: model.provider, model: model.model, url: model.url, - apiKey: model.apiKey, isSystem: model.isSystem, toolUsage: model.toolUsage, isConfidential: model.isConfidential, diff --git a/src/lib/pick-defaults.test.ts b/src/lib/pick-defaults.test.ts new file mode 100644 index 000000000..5afa25370 --- /dev/null +++ b/src/lib/pick-defaults.test.ts @@ -0,0 +1,101 @@ +/* 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 { defaultModelOpus48, defaultModels, defaultModelsVersion } from '@shared/defaults/models' +import { pickModelsDefaults } from './pick-defaults' + +const serverPayload = (version: number) => ({ + version, + data: [{ ...defaultModelOpus48, name: `Server v${version}` }], +}) + +describe('pickModelsDefaults', () => { + test('bundle wins when server is absent (offline / no fetch yet)', () => { + const picked = pickModelsDefaults(undefined) + expect(picked.version).toBe(defaultModelsVersion) + expect(picked.data).toBe(defaultModels) + }) + + test('server wins when it declares a strictly higher version', () => { + const server = serverPayload(defaultModelsVersion + 1) + const picked = pickModelsDefaults(server) + expect(picked.version).toBe(server.version) + expect(picked.data).toBe(server.data) + }) + + test('bundle wins when server declares an equal version (avoid needless swap)', () => { + const picked = pickModelsDefaults(serverPayload(defaultModelsVersion)) + expect(picked.version).toBe(defaultModelsVersion) + expect(picked.data).toBe(defaultModels) + }) + + test('bundle wins when server declares a lower version (rollback protection)', () => { + const picked = pickModelsDefaults(serverPayload(defaultModelsVersion - 1)) + expect(picked.version).toBe(defaultModelsVersion) + expect(picked.data).toBe(defaultModels) + }) + + test('bundle wins when server ships a bumped version with empty data (malformed payload)', () => { + // Otherwise cleanupRemovedDefaults would soft-delete every unedited system + // model against an empty currentModelIds set. + const picked = pickModelsDefaults({ version: defaultModelsVersion + 5, data: [] }) + expect(picked.version).toBe(defaultModelsVersion) + expect(picked.data).toBe(defaultModels) + }) + + test('bundle wins when server ships a bumped version with a non-array data value', () => { + // Runtime defense against a malformed JSON response the type system can't catch. + const picked = pickModelsDefaults({ + version: defaultModelsVersion + 5, + data: null as unknown as (typeof defaultModels)[number][], + }) + expect(picked.version).toBe(defaultModelsVersion) + expect(picked.data).toBe(defaultModels) + }) + + test('bundle wins when server ships a non-finite version (NaN / Infinity)', () => { + // A "bumped" version that isn't a real number is malformed — treating NaN as + // higher than the bundle would let bad server responses win. + for (const version of [Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]) { + const picked = pickModelsDefaults(serverPayload(version)) + expect(picked.version).toBe(defaultModelsVersion) + expect(picked.data).toBe(defaultModels) + } + }) + + test('bundle wins when server payload has zero overlap with bundled ids (wholesale replacement)', () => { + // Without this guard, `cleanupRemovedDefaults` would treat every bundle- + // known row as retired (nothing matches server's currentModelIds) and + // soft-delete them all, while the filtered reconcile pass would insert + // nothing (OTA-only-new ids have no bundled profile). Client would boot + // with zero system models. + const disjointPayload = { + version: defaultModelsVersion + 5, + data: [ + { ...defaultModelOpus48, id: 'disjoint-id-1', name: 'Fully Different Model 1' }, + { ...defaultModelOpus48, id: 'disjoint-id-2', name: 'Fully Different Model 2' }, + ], + } + const picked = pickModelsDefaults(disjointPayload) + expect(picked.version).toBe(defaultModelsVersion) + expect(picked.data).toBe(defaultModels) + }) + + test('server wins when the payload has any overlap with bundled ids (partial overlap ok)', () => { + // One overlapping id is enough to signal a non-pathological payload — + // retirement of some bundle ids via partial payload is a legitimate OTA + // use case and should still work. + const partialOverlap = { + version: defaultModelsVersion + 1, + data: [ + defaultModelOpus48, // in bundle + { ...defaultModelOpus48, id: 'new-id', name: 'Server-only New Model' }, + ], + } + const picked = pickModelsDefaults(partialOverlap) + expect(picked.version).toBe(defaultModelsVersion + 1) + expect(picked.data).toBe(partialOverlap.data) + }) +}) diff --git a/src/lib/pick-defaults.ts b/src/lib/pick-defaults.ts new file mode 100644 index 000000000..1a4457716 --- /dev/null +++ b/src/lib/pick-defaults.ts @@ -0,0 +1,57 @@ +/* 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 { defaultModels, defaultModelsVersion, type SharedModel } from '@shared/defaults/models' + +export type ModelsDefaults = { + version: number + data: readonly SharedModel[] +} + +type ServerModelsDefaults = { version: number; data: SharedModel[] } + +/** + * Pick between server-supplied and bundled models defaults, preferring the + * higher declared version. Behaves like an OTA channel: the server can hot-ship + * new defaults without a client build; when offline / unreachable / behind, the + * bundle wins. + * + * Rollback semantics are monotonic — a server that regresses its declared + * version below what the client already has will not overwrite. To retract a + * bad server-published set, ship a *higher* version with the reverted content. + * + * Sanity guards on the server payload (fall back to bundle when tripped): + * - version is a finite number strictly higher than the bundle's; + * - `data` is a non-empty array; + * - **at least one id in `data` overlaps with the bundle's `defaultModels`**. + * Without this, `cleanupRemovedDefaults` would treat every bundle-known + * row as retired (none appear in the server's `currentModelIds`) and + * soft-delete the lot, while the filtered reconcile pass would insert + * nothing (OTA-only-new ids have no bundled profile). Bundle acts as the + * floor: OTA can update or retire ids the bundle knows, but not wipe + * wholesale. + */ +export const pickModelsDefaults = (server: ServerModelsDefaults | undefined): ModelsDefaults => { + if ( + server && + Number.isFinite(server.version) && + server.version > defaultModelsVersion && + Array.isArray(server.data) && + server.data.length > 0 + ) { + const bundledIds = new Set(defaultModels.map((m) => m.id)) + if (server.data.some((m) => bundledIds.has(m.id))) { + return { version: server.version, data: server.data } + } + // Payload is well-formed but has zero overlap with the bundle. Either the + // server team shipped a wholesale replacement (needs a client build with + // matching profiles first) or the payload is misconfigured. Either way, + // adopting it would wipe local state — fall back to bundle and log. + console.warn( + `[pickModelsDefaults] Server payload rejected: ${server.data.length} model id(s) with zero overlap ` + + `against the ${defaultModels.length} bundled defaults. Falling back to the bundled lineup.`, + ) + } + return { version: defaultModelsVersion, data: defaultModels } +} diff --git a/src/lib/reconcile-defaults.test.ts b/src/lib/reconcile-defaults.test.ts index 4c5e51df9..d26537484 100644 --- a/src/lib/reconcile-defaults.test.ts +++ b/src/lib/reconcile-defaults.test.ts @@ -2,18 +2,18 @@ * 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 { getAllModels } from '@/dal' +import { deleteModel, getAllModels } from '@/dal' import { resetTestDatabase, setupTestDatabase, teardownTestDatabase } from '@/dal/test-utils' import { getDb } from '@/db/database' -import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test' import { eq } from 'drizzle-orm' import { modelProfilesTable, modelsTable, promptsTable, settingsTable } from '../db/tables' import { defaultAutomations, hashPrompt } from '../defaults/automations' -import { hashModelProfile } from '../defaults/model-profiles' -import { defaultModels, hashModel } from '../defaults/models' +import { defaultModelProfiles, hashModelProfile } from '../defaults/model-profiles' +import { defaultModels, defaultModelsVersion, hashModel, type SharedModel } from '@shared/defaults/models' import { defaultSettings, hashSetting } from '../defaults/settings' -import { nowIso } from './utils' -import { cleanupRemovedDefaults, reconcileDefaultsForTable } from './reconcile-defaults' +import type { ModelsDefaults } from './pick-defaults' +import { cleanupRemovedDefaults, reconcileDefaults, reconcileDefaultsForTable } from './reconcile-defaults' import type { Model, ModelProfile, Prompt } from '@/types' /** A model id no current default uses — stands in for any retired default. */ @@ -80,6 +80,14 @@ beforeAll(async () => { await setupTestDatabase() }) +// Also reset before each test — `setupTestDatabase` reconciles defaults into +// the DB, so without this guard the first test picked by --randomize inherits +// pre-populated rows (any raw `db.insert(modelsTable).values(defaultModels[N])` +// then hits a PK conflict). Between-test reset alone doesn't cover that gap. +beforeEach(async () => { + await resetTestDatabase() +}) + afterEach(async () => { await resetTestDatabase() }) @@ -151,8 +159,11 @@ describe('seedModels', () => { await db.update(modelsTable).set({ name: 'User Modified' }).where(eq(modelsTable.id, defaultModels[0].id)) // Scenario 2: Model 1 stays unmodified - // Scenario 3: Model 2 is deleted (soft delete) - await db.update(modelsTable).set({ deletedAt: nowIso() }).where(eq(modelsTable.id, defaultModels[2]?.id)) + // Scenario 3: Model 2 is user-deleted via the DAL — this scrubs the row's + // nullable columns (including defaultHash) via `clearNullableColumns`, + // which is what distinguishes a user delete from a cleanup soft-delete + // and prevents the resurrect branch from undoing it. + await deleteModel(db, defaultModels[2].id) // Seed again await reconcileDefaultsForTable(db, modelsTable, defaultModels, hashModel) @@ -180,8 +191,9 @@ describe('seedModels', () => { const modelsBefore = await getAllModels(getDb()) expect(modelsBefore.length).toBe(defaultModels.length) - // Soft delete a model - await db.update(modelsTable).set({ deletedAt: nowIso() }).where(eq(modelsTable.id, defaultModels[0].id)) + // User-delete a model via the DAL (scrubs nullable columns including + // defaultHash so the resurrect branch treats it as a real user deletion). + await deleteModel(db, defaultModels[0].id) // Get all models after deletion - should not include soft-deleted model const modelsAfter = await getAllModels(getDb()) @@ -352,7 +364,7 @@ describe('seedPrompts', () => { describe('reconcileDefaultsForTable', () => { test('inserts new defaults on first run', async () => { const db = getDb() - await reconcileDefaultsForTable(db, settingsTable, defaultSettings, hashSetting, 'key') + await reconcileDefaultsForTable(db, settingsTable, defaultSettings, hashSetting, { keyField: 'key' }) const settings = await db.select().from(settingsTable) // Should have all default settings plus anonymous_id @@ -369,14 +381,14 @@ describe('reconcileDefaultsForTable', () => { test('updates unmodified settings on re-seed', async () => { const db = getDb() - await reconcileDefaultsForTable(db, settingsTable, defaultSettings, hashSetting, 'key') + await reconcileDefaultsForTable(db, settingsTable, defaultSettings, hashSetting, { keyField: 'key' }) // Get an unmodified setting const setting = await db.select().from(settingsTable).where(eq(settingsTable.key, defaultSettings[0].key)).get() expect(setting).toBeDefined() // Seed again - should be idempotent - await reconcileDefaultsForTable(db, settingsTable, defaultSettings, hashSetting, 'key') + await reconcileDefaultsForTable(db, settingsTable, defaultSettings, hashSetting, { keyField: 'key' }) // Setting should still match default const settingAfterReseed = await db @@ -391,7 +403,7 @@ describe('reconcileDefaultsForTable', () => { test('preserves user modifications', async () => { const db = getDb() - await reconcileDefaultsForTable(db, settingsTable, defaultSettings, hashSetting, 'key') + await reconcileDefaultsForTable(db, settingsTable, defaultSettings, hashSetting, { keyField: 'key' }) // User modifies a setting const defaultSetting = defaultSettings[0] @@ -401,7 +413,7 @@ describe('reconcileDefaultsForTable', () => { .where(eq(settingsTable.key, defaultSetting.key)) // Seed again - await reconcileDefaultsForTable(db, settingsTable, defaultSettings, hashSetting, 'key') + await reconcileDefaultsForTable(db, settingsTable, defaultSettings, hashSetting, { keyField: 'key' }) // Should NOT be overwritten const setting = await db.select().from(settingsTable).where(eq(settingsTable.key, defaultSetting.key)).get() @@ -412,7 +424,7 @@ describe('reconcileDefaultsForTable', () => { test('handles mixed scenarios correctly', async () => { const db = getDb() - await reconcileDefaultsForTable(db, settingsTable, defaultSettings, hashSetting, 'key') + await reconcileDefaultsForTable(db, settingsTable, defaultSettings, hashSetting, { keyField: 'key' }) // Scenario 1: User modifies setting 0 await db.update(settingsTable).set({ value: 'modified' }).where(eq(settingsTable.key, defaultSettings[0].key)) @@ -420,7 +432,7 @@ describe('reconcileDefaultsForTable', () => { // Scenario 2: Setting 1 stays unmodified // Seed again - await reconcileDefaultsForTable(db, settingsTable, defaultSettings, hashSetting, 'key') + await reconcileDefaultsForTable(db, settingsTable, defaultSettings, hashSetting, { keyField: 'key' }) const settings = await db.select().from(settingsTable) @@ -454,7 +466,7 @@ describe('reconcileDefaultsForTable', () => { } // Seed with this default - await reconcileDefaultsForTable(db, settingsTable, [testDefault], hashSetting, 'key') + await reconcileDefaultsForTable(db, settingsTable, [testDefault], hashSetting, { keyField: 'key' }) // Should now have a defaultHash const setting = await db.select().from(settingsTable).where(eq(settingsTable.key, 'test_setting_no_hash')).get() @@ -503,7 +515,7 @@ describe('reconcileDefaultsForTable', () => { } // Run reconcile - this previously would overwrite user's "metric" with null - await reconcileDefaultsForTable(db, settingsTable, [nullDefault], hashSetting, 'key') + await reconcileDefaultsForTable(db, settingsTable, [nullDefault], hashSetting, { keyField: 'key' }) // User's value should be PRESERVED, not overwritten with null const afterReconcile = await db.select().from(settingsTable).where(eq(settingsTable.key, testKey)).get() @@ -514,12 +526,105 @@ describe('reconcileDefaultsForTable', () => { test('no-op when defaults array is empty', async () => { const db = getDb() - await reconcileDefaultsForTable(db, settingsTable, [], hashSetting, 'key') + await reconcileDefaultsForTable(db, settingsTable, [], hashSetting, { keyField: 'key' }) const settings = await db.select().from(settingsTable) expect(settings.length).toBe(0) }) + test('canOverwrite=false but canResurrect=true still resurrects a cleanup-shaped soft-delete', async () => { + const db = getDb() + + // Simulate a pre-THU-637 client's `cleanupRemovedDefaults` having + // soft-deleted a currently-shipped default: `deletedAt` is set but + // `defaultHash` still matches the content-minus-deletedAt (cleanup only + // touches deletedAt). Resurrect must fire on an older-bundle-but-fully + // -synced device (canOverwrite=false, canResurrect=true), otherwise the + // row stays deleted for good once `stored.version` catches up. + const shipped = defaultModels[0] + await db.insert(modelsTable).values({ + ...shipped, + deletedAt: '2026-01-01T00:00:00.000Z', + defaultHash: hashModel(shipped), + }) + + await reconcileDefaultsForTable(db, modelsTable, defaultModels, hashModel, { + canOverwrite: false, + canResurrect: true, + }) + + const row = await db.select().from(modelsTable).where(eq(modelsTable.id, shipped.id)).get() + expect(row?.deletedAt).toBeNull() + }) + + test('canResurrect=false skips resurrect even for a cleanup-shaped soft-delete', async () => { + const db = getDb() + + // Sync incomplete + populated table → `canResurrect=false`. The row's + // "soft-deleted" flag may be a partial-sync artefact of an authoritative + // retirement; un-deleting would race with cloud state. Leave it, retry + // on a later boot when sync has settled. + const shipped = defaultModels[0] + await db.insert(modelsTable).values({ + ...shipped, + deletedAt: '2026-01-01T00:00:00.000Z', + defaultHash: hashModel(shipped), + }) + + await reconcileDefaultsForTable(db, modelsTable, defaultModels, hashModel, { + canOverwrite: false, + canResurrect: false, + }) + + const row = await db.select().from(modelsTable).where(eq(modelsTable.id, shipped.id)).get() + expect(row?.deletedAt).toBe('2026-01-01T00:00:00.000Z') + }) + + test('canOverwrite=false does not resurrect a user-driven soft-delete', async () => { + const db = getDb() + + // User-driven `deleteModel` scrubs every nullable column via + // `clearNullableColumns` — most importantly, `defaultHash` becomes null. + // The resurrect guard's hash check can't satisfy a null defaultHash, so + // the user's deletion is preserved regardless of canResurrect. + const shipped = defaultModels[0] + await db.insert(modelsTable).values({ + ...shipped, + name: null, + model: null, + description: null, + vendor: null, + deletedAt: '2026-01-01T00:00:00.000Z', + defaultHash: null, + }) + + await reconcileDefaultsForTable(db, modelsTable, defaultModels, hashModel, { + canOverwrite: false, + canResurrect: true, + }) + + const row = await db.select().from(modelsTable).where(eq(modelsTable.id, shipped.id)).get() + expect(row?.deletedAt).toBe('2026-01-01T00:00:00.000Z') + }) + + test('canOverwrite=false skips overwrites of unedited existing rows', async () => { + const db = getDb() + + // Row authored by a newer bundle (same id as a current default, but + // different content, with an authoring hash that matches its content — + // i.e., it looks "unedited from a newer version's perspective"). + const bundleRow = defaultModels[0] + const newer: SharedModel = { ...bundleRow, name: 'Newer Bundle Name' } + await db.insert(modelsTable).values({ ...newer, defaultHash: hashModel(newer) }) + + // With canOverwrite=false the older bundle must not touch it. + await reconcileDefaultsForTable(db, modelsTable, defaultModels, hashModel, { canOverwrite: false }) + + const row = await db.select().from(modelsTable).where(eq(modelsTable.id, bundleRow.id)).get() + expect(row?.name).toBe('Newer Bundle Name') + expect(row?.defaultHash).toBe(hashModel(newer)) + }) + test('still updates when both existing and default values are null', async () => { const db = getDb() @@ -547,10 +652,533 @@ describe('reconcileDefaultsForTable', () => { } // Run reconcile - should proceed (this is a no-op anyway) - await reconcileDefaultsForTable(db, settingsTable, [nullDefault], hashSetting, 'key') + await reconcileDefaultsForTable(db, settingsTable, [nullDefault], hashSetting, { keyField: 'key' }) // Value should still be null (no change, but update was allowed) const afterReconcile = await db.select().from(settingsTable).where(eq(settingsTable.key, 'optional_setting')).get() expect(afterReconcile?.value).toBeNull() }) }) + +/** + * Regression tests for THU-637 ("Models are janky"): older-bundle devices used + * to overwrite rows authored by newer-bundle devices via sync, causing every + * launch to flap between old/new models. The version gate stops this. + */ +describe('reconcileDefaults version gate (THU-637)', () => { + const modelsVersionKey = 'defaults_version.models' + + const readStoredModelsVersion = async () => { + const db = getDb() + const row = await db.select().from(settingsTable).where(eq(settingsTable.key, modelsVersionKey)).get() + return row?.value == null ? null : Number(row.value) + } + + test('fresh install applies bundle defaults and stamps the applied version', async () => { + const db = getDb() + await reconcileDefaults(db) + + const models = await db.select().from(modelsTable) + for (const bundle of defaultModels) { + expect(models.find((m) => m.id === bundle.id)).toBeDefined() + } + + expect(await readStoredModelsVersion()).toBe(defaultModelsVersion) + }) + + test('older bundle does not downgrade rows authored by a newer version', async () => { + const db = getDb() + + // Prior application by a newer-version device: rows carry the newer + // content + matching authoring hash, and stored version is bumped past ours. + const [bundleRow, ...restBundle] = defaultModels + const newerRow: SharedModel = { ...bundleRow, name: 'Newer Bundle Name', description: 'from newer' } + await db.insert(modelsTable).values({ ...newerRow, defaultHash: hashModel(newerRow) }) + for (const other of restBundle) { + await db.insert(modelsTable).values({ ...other, defaultHash: hashModel(other) }) + } + await db.insert(settingsTable).values({ + key: modelsVersionKey, + value: String(defaultModelsVersion + 1), + }) + + await reconcileDefaults(db) + + const preserved = await db.select().from(modelsTable).where(eq(modelsTable.id, bundleRow.id)).get() + expect(preserved?.name).toBe('Newer Bundle Name') + expect(preserved?.description).toBe('from newer') + expect(preserved?.defaultHash).toBe(hashModel(newerRow)) + + // Older bundle must not regress the applied version. + expect(await readStoredModelsVersion()).toBe(defaultModelsVersion + 1) + }) + + test('older bundle does not soft-delete future defaults it does not recognize', async () => { + const db = getDb() + + // Newer version added a system model our bundle does not know about. + // It synced in with a matching authoring hash. Cleanup would normally + // remove it (id not in defaultModels, hash matches) — the gate must skip. + const futureRow = buildRetiredModel() + await db.insert(modelsTable).values(futureRow) + await db.insert(settingsTable).values({ + key: modelsVersionKey, + value: String(defaultModelsVersion + 1), + }) + + await reconcileDefaults(db) + + const alive = await db.select().from(modelsTable).where(eq(modelsTable.id, futureRow.id)).get() + expect(alive?.deletedAt).toBeNull() + }) + + test('newer bundle upgrades in place and advances the stored version', async () => { + const db = getDb() + + // Prime with the current bundle, then rewind to look like a prior version. + await reconcileDefaults(db) + const targetId = defaultModels[0].id + const staleRow = { ...defaultModels[0], name: 'stale name' } + await db + .update(modelsTable) + .set({ name: 'stale name', defaultHash: hashModel(staleRow) }) + .where(eq(modelsTable.id, targetId)) + await db + .update(settingsTable) + .set({ value: String(defaultModelsVersion - 1) }) + .where(eq(settingsTable.key, modelsVersionKey)) + + await reconcileDefaults(db) + + const upgraded = await db.select().from(modelsTable).where(eq(modelsTable.id, targetId)).get() + expect(upgraded?.name).toBe(defaultModels[0].name) + expect(upgraded?.defaultHash).toBe(hashModel(defaultModels[0])) + expect(await readStoredModelsVersion()).toBe(defaultModelsVersion) + }) + + test('user edits survive under both older and newer bundle passes', async () => { + const db = getDb() + await reconcileDefaults(db) + + // User renames a row after the first apply. + const editedId = defaultModels[0].id + await db.update(modelsTable).set({ name: 'user-picked name' }).where(eq(modelsTable.id, editedId)) + + // Older-bundle pass: rewind stored version — user edit must still survive. + await db + .update(settingsTable) + .set({ value: String(defaultModelsVersion + 1) }) + .where(eq(settingsTable.key, modelsVersionKey)) + await reconcileDefaults(db) + + const stillEdited = await db.select().from(modelsTable).where(eq(modelsTable.id, editedId)).get() + expect(stillEdited?.name).toBe('user-picked name') + }) + + test('non-numeric stored version routes to UPDATE, not INSERT (no PK conflict)', async () => { + const db = getDb() + + // Simulate a corrupted / previous-schema value in the version row. + await db.insert(settingsTable).values({ key: modelsVersionKey, value: 'not-a-number' }) + + // readAppliedVersion treats non-numeric as null-version-but-exists → gate opens, + // upsert must UPDATE the row. If it wrongly INSERTs, this throws on PK conflict. + await reconcileDefaults(db) + + expect(await readStoredModelsVersion()).toBe(defaultModelsVersion) + }) + + test('older bundle does not resurrect defaults its bundle happens to know but the newer version retired', async () => { + const db = getDb() + + // Newer version has already applied on this account (stored version bumped + // past our bundle) and has retired one of the defaults our bundle still + // ships. To exercise the insert branch we seed the *other* defaults but + // leave the retired one absent — cloud will deliver its soft-delete later. + const [retired, ...alive] = defaultModels + for (const other of alive) { + await db.insert(modelsTable).values({ ...other, defaultHash: hashModel(other) }) + } + await db.insert(settingsTable).values({ + key: 'defaults_version.models', + value: String(defaultModelsVersion + 1), + }) + + await reconcileDefaults(db) + + // Our older bundle must not seed the retired id. + const ghost = await db.select().from(modelsTable).where(eq(modelsTable.id, retired.id)).get() + expect(ghost).toBeUndefined() + }) + + test('sync-timeout + populated table + no stored version → skip mutations to avoid regressing marker', async () => { + const db = getDb() + + // Simulate a second device whose initial sync didn't complete: rows for + // some models are already present (partial sync), but the version marker + // hasn't been delivered yet. Acting on this partial view would let us + // downgrade or resurrect rows and regress the stored version once cloud + // finally delivers it. + const alive = defaultModels[0] + const newerContent = { ...alive, name: 'Newer from cloud' } + await db.insert(modelsTable).values({ ...newerContent, defaultHash: hashModel(newerContent) }) + + await reconcileDefaults(db, { initialSyncCompleted: false }) + + // The newer-content row must survive intact. + const preserved = await db.select().from(modelsTable).where(eq(modelsTable.id, alive.id)).get() + expect(preserved?.name).toBe('Newer from cloud') + + // And the version marker must not be written — that would poison the next + // boot's gate calculation. + expect(await readStoredModelsVersion()).toBeNull() + }) + + test('sync-timeout + empty table → still seeds bundle (first-ever install)', async () => { + // Otherwise a fresh install offline (network flaky, first launch) would + // boot with zero models. + const db = getDb() + await reconcileDefaults(db, { initialSyncCompleted: false }) + + const models = await db.select().from(modelsTable) + expect(models.length).toBe(defaultModels.length) + expect(await readStoredModelsVersion()).toBe(defaultModelsVersion) + }) + + test('marker does not advance when reconcile is a pure no-op (all rows user-edited)', async () => { + const db = getDb() + + // Seed at the current bundle so all rows have their bundle's defaultHash. + await reconcileDefaults(db) + + // User edits every model row so no update can proceed on the next pass. + // Also rewind the marker to look like we're catching up from an older + // stored version — this opens the gate (rawCanOverwrite=true) but the + // pass will still be a total no-op because every row is now user-edited. + for (const model of defaultModels) { + await db + .update(modelsTable) + .set({ name: `user-edited ${model.id}` }) + .where(eq(modelsTable.id, model.id)) + } + await db + .update(settingsTable) + .set({ value: String(defaultModelsVersion - 1) }) + .where(eq(settingsTable.key, modelsVersionKey)) + + await reconcileDefaults(db) + + // Marker must stay at the rewound value — advancing it here would signal + // to peers that this version was applied when in fact nothing was written. + expect(await readStoredModelsVersion()).toBe(defaultModelsVersion - 1) + }) + + test('sync-incomplete + populated table + stale stored version → skip mutations', async () => { + const db = getDb() + + // Populate a row with newer-authored content and a stale marker below the + // bundle. Before the sync-outcome guard was broadened, this scenario let + // rawCanOverwrite (bundle > stale-stored) reopen the gate and downgrade + // rows that cloud may have already advanced past. + const alive = defaultModels[0] + const newerContent = { ...alive, name: 'Newer from cloud' } + await db.insert(modelsTable).values({ ...newerContent, defaultHash: hashModel(newerContent) }) + await db.insert(settingsTable).values({ + key: modelsVersionKey, + value: String(defaultModelsVersion - 1), + }) + + await reconcileDefaults(db, { initialSyncCompleted: false }) + + const preserved = await db.select().from(modelsTable).where(eq(modelsTable.id, alive.id)).get() + expect(preserved?.name).toBe('Newer from cloud') + expect(await readStoredModelsVersion()).toBe(defaultModelsVersion - 1) + }) + + test('missing profile is inserted even when canOverwriteModels is false (model↔profile 1:1)', async () => { + const db = getDb() + + // Model row exists locally (from sync, say). Profile row hasn't arrived + // yet. Stored marker is newer than our bundle, so canOverwriteModels=false. + // Under strict gating the profile insert would be skipped and the model + // would boot without its default profile — a runtime hazard. `insertMissing` + // on the profiles call restores the 1:1 invariant. + const model = defaultModels[0] + await db.insert(modelsTable).values({ ...model, defaultHash: hashModel(model) }) + await db.insert(settingsTable).values({ + key: modelsVersionKey, + value: String(defaultModelsVersion + 1), + }) + + await reconcileDefaults(db) + + const profile = await db.select().from(modelProfilesTable).where(eq(modelProfilesTable.modelId, model.id)).get() + expect(profile).toBeDefined() + expect(profile?.deletedAt).toBeNull() + }) + + test('older bundle does not overwrite profiles authored by a newer version', async () => { + const db = getDb() + + // Seed at the current bundle so profiles get their defaultHash. + await reconcileDefaults(db) + + // Simulate a newer-version device having tweaked a profile field + // (temperature) — content matches its authoring hash, so it looks + // "unedited from the newer bundle's perspective". + const profileModelId = defaultModels[0].id + const original = await db + .select() + .from(modelProfilesTable) + .where(eq(modelProfilesTable.modelId, profileModelId)) + .get() + expect(original).toBeDefined() + const newer = { ...original!, temperature: 0.77 } + await db + .update(modelProfilesTable) + .set({ temperature: 0.77, defaultHash: hashModelProfile(newer) }) + .where(eq(modelProfilesTable.modelId, profileModelId)) + + // Rewind stored models version so canOverwriteModels goes false on next pass. + await db + .update(settingsTable) + .set({ value: String(defaultModelsVersion + 1) }) + .where(eq(settingsTable.key, modelsVersionKey)) + + await reconcileDefaults(db) + + const preserved = await db + .select() + .from(modelProfilesTable) + .where(eq(modelProfilesTable.modelId, profileModelId)) + .get() + expect(preserved?.temperature).toBe(0.77) + }) + + test('older-bundle-but-fully-synced device resurrects a pre-THU-637 cleanup soft-delete', async () => { + const db = getDb() + + // Set up the cross-branch race scenario: a pre-THU-637 client soft- + // deleted a shipped model (cleanup shape — only deletedAt set, content + + // defaultHash preserved). This device has bundle=V2 but stored=V2 already + // (marker synced from another combined-PR device), so canOverwrite is + // closed. initialSyncCompleted defaults to true — sync is settled. + // Resurrect must still fire so the account recovers. + const shipped = defaultModels[0] + await db.insert(modelsTable).values({ + ...shipped, + deletedAt: '2026-01-01T00:00:00.000Z', + defaultHash: hashModel(shipped), + }) + await db.insert(settingsTable).values({ + key: modelsVersionKey, + value: String(defaultModelsVersion), + }) + + await reconcileDefaults(db) + + const revived = await db.select().from(modelsTable).where(eq(modelsTable.id, shipped.id)).get() + expect(revived?.deletedAt).toBeNull() + }) + + test('sync-incomplete does not resurrect (may race an authoritative deletion)', async () => { + const db = getDb() + + // Same cleanup-shaped row, but sync didn't complete this boot. The soft- + // delete could be a partial-sync artefact of a genuine retirement; acting + // on our incomplete view risks undoing a legitimate cleanup. Skip and + // retry on a settled boot. + const shipped = defaultModels[0] + await db.insert(modelsTable).values({ + ...shipped, + deletedAt: '2026-01-01T00:00:00.000Z', + defaultHash: hashModel(shipped), + }) + + await reconcileDefaults(db, { initialSyncCompleted: false }) + + const stillGone = await db.select().from(modelsTable).where(eq(modelsTable.id, shipped.id)).get() + expect(stillGone?.deletedAt).toBe('2026-01-01T00:00:00.000Z') + }) + + test('sync-incomplete does not soft-delete orphaned profiles (partial-view protection)', async () => { + const db = getDb() + + // Simulate a partial sync: the parent model looks locally missing (never + // arrived, or its delete propagated first) while an alive profile row + // pointing at it sits in local state. Under normal (sync-complete) flow + // the profile-cleanup loop would soft-delete this orphan; under sync- + // incomplete the parent may still be alive on cloud and we must stay + // non-mutating for the profiles table. + const stubProfile = defaultModelProfiles[0] + const orphanModelId = 'orphan-parent-id' + await db.insert(modelProfilesTable).values({ + ...stubProfile, + modelId: orphanModelId, + defaultHash: hashModelProfile({ ...stubProfile, modelId: orphanModelId }), + }) + + await reconcileDefaults(db, { initialSyncCompleted: false }) + + const profile = await db + .select() + .from(modelProfilesTable) + .where(eq(modelProfilesTable.modelId, orphanModelId)) + .get() + expect(profile?.deletedAt).toBeNull() + }) + + test('OTA that retires a bundle-known model does not strand its profile (fresh device)', async () => { + const db = getDb() + + // Server retires the first bundle model by omitting it from `data`. On a + // fresh device the models pass never inserts it; the profiles pass must + // NOT hit `insertMissing` for its profile — otherwise we'd have a profile + // row pointing at a model that doesn't exist locally. + const [retired, ...remaining] = defaultModels + const otaSource: ModelsDefaults = { + version: defaultModelsVersion + 1, + data: [...remaining], + } + + await reconcileDefaults(db, { models: otaSource }) + + const ghostModel = await db.select().from(modelsTable).where(eq(modelsTable.id, retired.id)).get() + expect(ghostModel).toBeUndefined() + const ghostProfile = await db + .select() + .from(modelProfilesTable) + .where(eq(modelProfilesTable.modelId, retired.id)) + .get() + expect(ghostProfile).toBeUndefined() + }) + + test('OTA that retires a bundle-known model does not resurrect its cleanup-soft-deleted profile', async () => { + const db = getDb() + + // Prime with the current bundle so both model and profile carry their + // authoring hashes. + await reconcileDefaults(db) + + // Server ships a higher version that drops the first model. + // `cleanupRemovedDefaults` will soft-delete both the model and its profile + // (their content still matches authoring hashes). The profiles pass then + // runs — with the pre-fix logic it would enter the resurrect branch on + // the profile (hash match, canResurrect open) and un-delete an orphan. + const [retired, ...remaining] = defaultModels + const otaSource: ModelsDefaults = { + version: defaultModelsVersion + 1, + data: [...remaining], + } + + await reconcileDefaults(db, { models: otaSource }) + + const modelRow = await db.select().from(modelsTable).where(eq(modelsTable.id, retired.id)).get() + expect(modelRow?.deletedAt).not.toBeNull() + const profileRow = await db + .select() + .from(modelProfilesTable) + .where(eq(modelProfilesTable.modelId, retired.id)) + .get() + expect(profileRow?.deletedAt).not.toBeNull() + }) + + test('OTA cannot flip isConfidential or provider on a bundle-known id (frozen fields)', async () => { + const db = getDb() + + // Seed at the current bundle so all rows have their authoring hash. + await reconcileDefaults(db) + + // Server ships a higher-version payload that flips `isConfidential` and + // `provider` on an existing bundle-known id, plus a legitimate change to + // `name` and `description`. Frozen fields must survive; unfrozen ones must + // still update. + const target = defaultModels[0] + const flipped: SharedModel = { + ...target, + name: 'Renamed Via OTA', + description: 'renamed description', + isConfidential: target.isConfidential === 1 ? 0 : 1, + provider: target.provider === 'thunderbolt' ? 'openai' : 'thunderbolt', + } + const otaSource: ModelsDefaults = { + version: defaultModelsVersion + 1, + data: [flipped, ...defaultModels.slice(1)], + } + + await reconcileDefaults(db, { models: otaSource }) + + const row = await db.select().from(modelsTable).where(eq(modelsTable.id, target.id)).get() + // Frozen fields keep the original values... + expect(row?.isConfidential).toBe(target.isConfidential) + expect(row?.provider).toBe(target.provider) + // ...while unfrozen fields adopt the OTA payload. + expect(row?.name).toBe('Renamed Via OTA') + expect(row?.description).toBe('renamed description') + + // The stored hash must match a hash of the effective (post-freeze) row, or + // the next reconcile would treat this row as user-edited and never update. + const effectiveExpected = { ...flipped, isConfidential: target.isConfidential, provider: target.provider } + expect(row?.defaultHash).toBe(hashModel(effectiveExpected)) + + // A follow-up reconcile with the same payload is a no-op on this row. + await reconcileDefaults(db, { models: otaSource }) + const rowAgain = await db.select().from(modelsTable).where(eq(modelsTable.id, target.id)).get() + expect(rowAgain?.name).toBe('Renamed Via OTA') + expect(rowAgain?.defaultHash).toBe(hashModel(effectiveExpected)) + }) + + test('OTA models without a bundled profile are dropped and do not advance the marker', async () => { + const db = getDb() + + // Simulate an OTA payload that includes a model whose id this client's + // bundle has no profile for (a genuine "new model" scenario the OTA + // channel can express but this bundle can't fully render because profiles + // aren't part of `/config`). The dropped id must not be inserted, and + // the marker must not advance to the OTA version — otherwise a later + // client with the fuller bundle would see `stored=OTA.version` and its + // canOverwrite would be closed, permanently blocking the missing insert. + const unknownId = '019fa11c-0000-7000-b000-abcdefabcdef' + const otaSource: ModelsDefaults = { + version: defaultModelsVersion + 1, + data: [ + ...defaultModels, + { + id: unknownId, + name: 'Server-only Future Model', + provider: 'thunderbolt', + model: 'future-model', + isSystem: 1, + enabled: 1, + isConfidential: 0, + contextWindow: 100_000, + toolUsage: 1, + startWithReasoning: 0, + supportsParallelToolCalls: 0, + deletedAt: null, + url: null, + defaultHash: null, + vendor: null, + description: null, + userId: null, + }, + ], + } + + await reconcileDefaults(db, { models: otaSource }) + + const ghost = await db.select().from(modelsTable).where(eq(modelsTable.id, unknownId)).get() + expect(ghost).toBeUndefined() + + // Bundle-known models still applied. + for (const known of defaultModels) { + const row = await db.select().from(modelsTable).where(eq(modelsTable.id, known.id)).get() + expect(row).toBeDefined() + } + + // Marker did not advance to OTA version — no client is fully at that + // version yet, so peers with the fuller bundle should still be able to + // reach open canOverwrite on their next boot. + expect(await readStoredModelsVersion()).not.toBe(defaultModelsVersion + 1) + }) +}) diff --git a/src/lib/reconcile-defaults.ts b/src/lib/reconcile-defaults.ts index 2ae0bf410..564554720 100644 --- a/src/lib/reconcile-defaults.ts +++ b/src/lib/reconcile-defaults.ts @@ -11,58 +11,191 @@ import { v7 as uuidv7 } from 'uuid' import { modelProfilesTable, modelsTable, modesTable, settingsTable, skillsTable, tasksTable } from '../db/tables' import { defaultModelProfiles, hashModelProfile } from '../defaults/model-profiles' import { defaultModes, hashMode } from '../defaults/modes' -import { defaultModels, hashModel } from '../defaults/models' +import { defaultModels, defaultModelsVersion, hashModel, type SharedModel } from '@shared/defaults/models' import { defaultSettings, hashSetting } from '../defaults/settings' import { defaultSkills, hashSkill } from '../defaults/skills' import { defaultTasks, hashTask } from '../defaults/tasks' +import type { ModelsDefaults } from './pick-defaults' import { nowIso } from './utils' +const bundledModelsDefaults: ModelsDefaults = { version: defaultModelsVersion, data: defaultModels } + +/** + * Settings key holding the highest defaults version ever applied to this + * account's models table. See "Reconciled defaults and version bumps" in + * AGENTS.md and the THU-637 rationale for the version-gate design. + */ +const modelsVersionKey = 'defaults_version.models' + +/** + * Read a previously-recorded defaults version from `settingsTable`. Returns + * `exists` separately from `version` so callers can tell "row absent" from + * "row present with garbage value" — the two need to branch to insert vs. + * update on write-back. `version` is null when the row is absent OR its value + * is not a finite number (treat as "safe to apply" for the gate comparison). + */ +const readAppliedVersion = async ( + db: AnyDrizzleDatabase, + key: string, +): Promise<{ exists: boolean; version: number | null }> => { + const rows = await db.select().from(settingsTable).where(eq(settingsTable.key, key)) + const row = rows[0] + if (!row) { + return { exists: false, version: null } + } + if (row.value == null) { + return { exists: true, version: null } + } + const parsed = Number(row.value) + return { exists: true, version: Number.isFinite(parsed) ? parsed : null } +} + +/** + * Options for `reconcileDefaultsForTable`. + * + * @property keyField - Name of the primary key field. Defaults to `'id'`. + * @property canOverwrite - When false, this pass will not update existing rows + * and will not bootstrap the legacy null-defaultHash column. Set to false + * when the caller's defaults source is not authoritative — either strictly + * older than what has already been applied on this account, or when the + * account's true state hasn't finished syncing yet. + * @property insertMissing - When true, insert missing rows even if + * `canOverwrite` is false. Set for tables where a row's presence is a hard + * invariant regardless of authority (e.g. model profiles are 1:1 with their + * parent model — a model without its profile is a runtime hazard, whereas a + * briefly-stale profile self-heals on sync). Defaults to `canOverwrite` so + * tables like models keep ghost-insert protection (see THU-637 / AGENTS.md). + * @property canResurrect - When true, undo a cleanup-shaped soft-delete of a + * still-shipped default. Gated separately from `canOverwrite` because the + * two conditions decide different things: `canOverwrite` is "authoritative + * to write our bundle's content", `canResurrect` is "trustworthy view of + * cloud state". A device with an older bundle but fully-synced state can + * safely undo a pre-THU-637 client's mistaken cleanup — while a device + * mid-sync must not, because its local "soft-deleted" flag may just be + * partial delivery of an authoritative retirement. Defaults to + * `canOverwrite` for callers that don't split the two signals. + * @property frozenFields - Field names that must never change on an existing + * row via reconcile. When updating, the existing row's value is kept for + * each listed field and the stored `defaultHash` reflects that + * post-freeze state. Protects identity-critical columns whose values + * establish downstream contracts — e.g. `isConfidential` on models + * (encrypted threads bind to it at creation) and `provider` (routing). + * A server-shipped OTA payload cannot flip these on a bundle-known id; + * a new value ships under a fresh id. Only applies to updates — inserts + * use the default as-is. + */ +export type ReconcileDefaultsForTableOptions = { + keyField?: string + canOverwrite?: boolean + insertMissing?: boolean + canResurrect?: boolean + frozenFields?: readonly string[] +} + +/** + * Result of a `reconcileDefaultsForTable` pass. + * + * @property mutated - True iff at least one row was inserted or updated (this + * includes the legacy null-defaultHash bootstrap). Callers use this to + * decide whether to advance an external version marker — advancing when + * nothing was actually written would falsely signal to peers that the + * picked version has been applied here. + */ +export type ReconcileDefaultsForTableResult = { mutated: boolean } + /** * Generic function to reconcile defaults into a table - * Inserts new defaults and updates unmodified existing ones + * Inserts new defaults and updates unmodified existing ones. * * Fetches all matching rows in a single SELECT (instead of one per item) to * minimize serial round-trips to the SQLite worker during boot. - * @param table - The database table to reconcile - * @param defaults - Array of default items to reconcile - * @param hashFn - Function to compute hash of an item - * @param keyField - Name of the primary key field (defaults to 'id') */ export const reconcileDefaultsForTable = async ( db: AnyDrizzleDatabase, table: SQLiteTableWithColumns, defaults: readonly T[], hashFn: (item: any) => string, - keyField: string = 'id', -) => { + options: ReconcileDefaultsForTableOptions = {}, +): Promise => { + const { + keyField = 'id', + canOverwrite = true, + insertMissing = canOverwrite, + canResurrect = canOverwrite, + frozenFields = [], + } = options + if (defaults.length === 0) { - return + return { mutated: false } } const keyValues = defaults.map((defaultItem) => (defaultItem as any)[keyField]) const existingRows = await db.select().from(table).where(inArray(table[keyField], keyValues)) const existingByKey = new Map(existingRows.map((row) => [row[keyField], row] as const)) + let mutated = false + for (const defaultItem of defaults) { const keyValue = (defaultItem as any)[keyField] const existing = existingByKey.get(keyValue) if (!existing) { - // New default - insert with computed hash + // Row missing locally: only seed when we're allowed to. For most tables + // that mirrors `canOverwrite` (ghost-insert protection). Tables that opt + // into `insertMissing: true` seed regardless because their row must + // exist for correctness (e.g. profiles paired with models). + if (!insertMissing) { + continue + } await db.insert(table).values({ ...defaultItem, defaultHash: hashFn(defaultItem), }) + mutated = true + continue + } + + // Resurrect a row soft-deleted by an older-build (pre-THU-637) client's + // unconditional `cleanupRemovedDefaults`. Two properties make this safe: + // 1. Cleanup only mutates `deletedAt` — content and `defaultHash` are + // preserved. User-driven `deleteModel` scrubs every nullable column + // via `clearNullableColumns`, including `defaultHash`, so a + // user-initiated soft-delete cannot satisfy the hash check below. + // 2. `hashFn({...existing, deletedAt: null}) === existing.defaultHash` + // confirms the row's content still matches the exact default that + // the previous reconciler stamped — this can only be a cleanup + // soft-delete of a still-shipped default that just needs undoing. + // Gated by `canResurrect` (not `canOverwrite`): a device with an older + // bundle but fully-synced state can still safely un-delete a mistaken + // cleanup, but a device mid-sync must not act on a possibly-partial + // view of "soft-deleted". Regardless of the write decision, we `continue` + // past this row — a cleanup-shaped row shouldn't fall through to the + // user-edit branch (its `deletedAt` would trip that as a false positive). + if ( + (existing as { deletedAt?: string | null }).deletedAt && + existing.defaultHash && + hashFn({ ...existing, deletedAt: null }) === existing.defaultHash + ) { + if (canResurrect) { + await db.update(table).set({ deletedAt: null }).where(eq(table[keyField], keyValue)) + mutated = true + } + continue + } + + // Any write against an existing row requires authority. + if (!canOverwrite) { continue } // Exists - check if user modified by comparing hashes const currentHash = hashFn(existing) - const defaultHashValue = hashFn(defaultItem) if (!existing.defaultHash) { // No defaultHash - set it to the default hash to enable modification tracking + const defaultHashValue = hashFn(defaultItem) await db.update(table).set({ defaultHash: defaultHashValue }).where(eq(table[keyField], keyValue)) + mutated = true continue } @@ -71,9 +204,20 @@ export const reconcileDefaultsForTable = async ((acc, field) => ({ ...acc, [field]: (existing as any)[field] }), defaultItem) as T) + const effectiveHash = frozenFields.length === 0 ? hashFn(defaultItem) : hashFn(effectiveDefault) + + // Skip update if the effective default matches what's already stored + // (prevents empty PATCH operations, and collapses OTA payloads that only + // touch frozen fields to a no-op). + if (existing.defaultHash === effectiveHash) { continue } @@ -81,7 +225,7 @@ export const reconcileDefaultsForTable = async { +export const cleanupRemovedDefaults = async ( + db: AnyDrizzleDatabase, + canOverwriteModels: boolean = true, + models: readonly SharedModel[] = defaultModels, + initialSyncCompleted: boolean = true, +) => { const now = nowIso() - const currentModelIds = new Set(defaultModels.map((m) => m.id)) + const currentModelIds = new Set(models.map((m) => m.id)) // One SELECT serves both loops: the system-model scan below and the // alive-model set used by the profile loop. Models soft-deleted in the scan @@ -114,21 +276,26 @@ export const cleanupRemovedDefaults = async (db: AnyDrizzleDatabase) => { const aliveModels = (await db.select().from(modelsTable).where(isNull(modelsTable.deletedAt))) as Model[] const aliveModelIds = new Set(aliveModels.map((m) => m.id)) - for (const row of aliveModels) { - if (row.isSystem !== 1 || currentModelIds.has(row.id) || !row.defaultHash) { - continue - } - if (hashModel(row) === row.defaultHash) { - await db.update(modelsTable).set({ deletedAt: now }).where(eq(modelsTable.id, row.id)) - aliveModelIds.delete(row.id) + if (canOverwriteModels) { + for (const row of aliveModels) { + if (row.isSystem !== 1 || currentModelIds.has(row.id) || !row.defaultHash) { + continue + } + if (hashModel(row) === row.defaultHash) { + await db.update(modelsTable).set({ deletedAt: now }).where(eq(modelsTable.id, row.id)) + aliveModelIds.delete(row.id) + } } } // Profiles are 1:1 with models. Mirror the model loop's "edited rows survive" // rule by following the parent model's fate — only delete the profile when - // its parent is no longer alive. Otherwise a user who renamed a retired - // default model but left the profile at shipped defaults would be left with - // an orphaned model. + // its parent is no longer alive. Gated on `initialSyncCompleted` so that a + // mid-sync device (with a potentially partial view of parent aliveness) + // stays fully non-mutating for the profiles table this pass. + if (!initialSyncCompleted) { + return + } const profiles = (await db .select() .from(modelProfilesTable) @@ -143,16 +310,122 @@ export const cleanupRemovedDefaults = async (db: AnyDrizzleDatabase) => { } } -export const reconcileDefaults = async (db: AnyDrizzleDatabase) => { +export type ReconcileDefaultsOverrides = { + /** Models defaults source (server OTA payload or bundled). Falls back to + * the shipped `defaultModels` + `defaultModelsVersion` when omitted. */ + models?: ModelsDefaults + /** Did PowerSync's initial-sync gate finish this boot? When false (timed out + * or failed) we can't trust that a missing local stored version means "never + * applied" — cloud may hold both the version marker and newer rows we + * haven't received yet. Defaults to true (tests / offline first-run). */ + initialSyncCompleted?: boolean +} + +export const reconcileDefaults = async (db: AnyDrizzleDatabase, overrides?: ReconcileDefaultsOverrides) => { + const modelsSource = overrides?.models ?? bundledModelsDefaults + const initialSyncCompleted = overrides?.initialSyncCompleted ?? true + await db.transaction(async (tx) => { + // Version gate for models: only overwrite rows when our defaults source + // is strictly newer than the highest version ever applied on this account. + // Prevents older-bundle devices from downgrading newer synced rows (THU-637). + const stored = await readAppliedVersion(tx, modelsVersionKey) + const rawCanOverwrite = modelsSource.version > (stored.version ?? Number.NEGATIVE_INFINITY) + + // Additional guard for the sync-incomplete case. Two scenarios collapse + // into the same rule: (a) fresh second device where the version marker + // hasn't arrived yet, and (b) any device with a stale local marker whose + // row content may already be at a newer version in cloud. In both, cloud + // may hold state we haven't received, and rawCanOverwrite is computed + // against an untrusted view. Fresh installs (0 rows) still seed the + // bundle so the app isn't crippled offline. + const hasAnyModelRow = (await tx.select({ id: modelsTable.id }).from(modelsTable).limit(1)).length > 0 + const canOverwriteModels = ((): boolean => { + if (!hasAnyModelRow) { + return rawCanOverwrite + } + if (!initialSyncCompleted) { + return false + } + return rawCanOverwrite + })() + + // OTA can ship models whose id isn't in this bundle's `defaultModelProfiles` + // — profiles are not part of the OTA channel, so we have no profile to + // pair with them. Inserting the model row alone violates the 1:1 model↔ + // profile invariant `insertMissing: true` is meant to preserve. Filter + // those ids out and log the drop; cleanup still uses the unfiltered set + // so a genuinely-shipped-elsewhere row (present locally via sync from a + // newer-bundle peer) stays alive. + const bundledProfileModelIds = new Set(defaultModelProfiles.map((p) => p.modelId)) + const modelsForReconcile = modelsSource.data.filter((m) => bundledProfileModelIds.has(m.id)) + const droppedOtaModelIds = modelsSource.data.filter((m) => !bundledProfileModelIds.has(m.id)).map((m) => m.id) + if (droppedOtaModelIds.length > 0) { + console.warn( + `[reconcileDefaults] Dropped ${droppedOtaModelIds.length} OTA model(s) without a bundled profile: ` + + `${droppedOtaModelIds.join(', ')}. OTA can only re-version or retire models this bundle knows; ` + + `adding a new model id requires a client build so its profile ships alongside.`, + ) + } + // Soft-delete removed system defaults before reconciling current ones. - await cleanupRemovedDefaults(tx) + // Cleanup uses the unfiltered OTA set so any id the server still ships + // (including new-to-us ones synced from a newer-bundle peer) stays alive. + // + // Safety invariant enforced upstream by `pickModelsDefaults`: + // `modelsSource.data` overlaps with `defaultModels` by at least one id. + // A fully-disjoint payload would otherwise let cleanup soft-delete every + // bundle-known row (none appear in the passed-in `currentModelIds`). + await cleanupRemovedDefaults(tx, canOverwriteModels, modelsSource.data, initialSyncCompleted) - // AI models - await reconcileDefaultsForTable(tx, modelsTable, defaultModels, hashModel) + // AI models. `canResurrect` uses initialSyncCompleted so an older-bundle + // but fully-synced device can still un-delete a pre-THU-637 mistake, while + // a mid-sync device stays non-mutating. + // + // `frozenFields` protects two identity-critical columns from OTA drift: + // - `isConfidential`: an encrypted thread binds `isEncrypted` to the + // model's `isConfidential` at creation and the send guard enforces + // equality; a server slip that flips this on a live row would strand + // every encrypted thread bound to that id (see the Flash-fresh-id + // comment in `shared/defaults/models.ts`). + // - `provider`: routes inference to the correct upstream; flipping it + // under an existing id would silently misroute or break sends. + // OTA can still update name, description, contextWindow, tool flags, etc. + // To change a frozen field, ship the new value under a fresh model id. + const modelsPass = await reconcileDefaultsForTable(tx, modelsTable, modelsForReconcile, hashModel, { + canOverwrite: canOverwriteModels, + canResurrect: initialSyncCompleted, + frozenFields: ['isConfidential', 'provider'], + }) - // Model profiles (after models, because they reference model IDs) - await reconcileDefaultsForTable(tx, modelProfilesTable, defaultModelProfiles, hashModelProfile, 'modelId') + // Model profiles ship 1:1 with models and mutate together in practice, so + // they ride the same authority gate as models — otherwise an older-bundle + // device would revert profile settings (temperature, tools, addenda) that + // a newer bundle just shipped alongside its model changes, reintroducing + // THU-637 on the profile side. `insertMissing: true` ensures a bundle- + // known model always has its bundled profile to pair with, even when + // canOverwrite is closed. + // + // Bounded to `modelsForReconcile`: this excludes both OTA-only-new ids + // (no bundled profile exists) and OTA-retired ids (dropped from the + // payload). Without this filter, `insertMissing: true` would insert a + // profile for a model the OTA channel just retired — an orphan profile + // with no live model — reintroducing the very 1:1 hazard `insertMissing` + // exists to prevent, from the OTA-retire direction. + const aliveModelIdsForReconcile = new Set(modelsForReconcile.map((m) => m.id)) + const profilesForReconcile = defaultModelProfiles.filter((p) => aliveModelIdsForReconcile.has(p.modelId)) + const profilesPass = await reconcileDefaultsForTable( + tx, + modelProfilesTable, + profilesForReconcile, + hashModelProfile, + { + keyField: 'modelId', + canOverwrite: canOverwriteModels, + insertMissing: true, + canResurrect: initialSyncCompleted, + }, + ) // Modes await reconcileDefaultsForTable(tx, modesTable, defaultModes, hashMode) @@ -165,7 +438,32 @@ export const reconcileDefaults = async (db: AnyDrizzleDatabase) => { await reconcileDefaultsForTable(tx, skillsTable, defaultSkills, hashSkill) // Settings - await reconcileDefaultsForTable(tx, settingsTable, defaultSettings, hashSetting, 'key') + await reconcileDefaultsForTable(tx, settingsTable, defaultSettings, hashSetting, { keyField: 'key' }) + + // Only advance the version marker when we actually applied a change to + // models or profiles this pass. Advancing on a pure no-op (all rows + // user-edited, or all already at target) would falsely signal to peers + // that the picked version has been applied here — peers with `stored` + // already at `pickedVersion` would then stop in-place upgrades even + // though the writer never verified the content. + // + // Also skip the marker when we filtered any OTA models: our apply is a + // strict subset of the picked version, and stamping the full version + // would lock later fuller-bundle clients (canOverwrite=false because + // stored>=picked) out of inserting the missing models. + if (canOverwriteModels && (modelsPass.mutated || profilesPass.mutated) && droppedOtaModelIds.length === 0) { + // Inline upsert: `updateSettings` wraps its writes in its own transaction + // and PowerSync's drizzle driver forbids nested transactions. Branch on + // row existence — not on parsed version — so a pre-existing row with a + // non-numeric value (data corruption, older schema) still routes to + // UPDATE instead of hitting a primary-key conflict on INSERT. + const versionValue = String(modelsSource.version) + if (!stored.exists) { + await tx.insert(settingsTable).values({ key: modelsVersionKey, value: versionValue }) + } else { + await tx.update(settingsTable).set({ value: versionValue }).where(eq(settingsTable.key, modelsVersionKey)) + } + } // Initialize anonymous ID for analytics (unique per user) await createSetting(tx, 'anonymous_id', uuidv7()) diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 3dd10aeb7..8c57d974e 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -212,20 +212,11 @@ export const splitPartType = (type: string): [string, string] => { return [type.slice(0, dashIndex), type.slice(dashIndex + 1)] } -/** - * Compute a simple hash from an array of values - * Uses a basic hash algorithm suitable for change detection - */ -export const hashValues = (values: (string | number | null | undefined)[]): string => { - const str = values.join('|') - let hash = 0 - for (let i = 0; i < str.length; i++) { - const char = str.charCodeAt(i) - hash = (hash << 5) - hash + char - hash = hash & hash // Convert to 32-bit integer - } - return hash.toString(36) -} +// Re-exported from the shared package so backend, frontend, and the shared +// defaults module all fingerprint values identically. Stored `defaultHash` +// values in user databases depend on this being byte-for-byte stable across +// call sites — see `shared/lib/hash.ts` for the load-bearing contract. +export { hashValues } from '@shared/lib/hash' /** * Format tool output as a string, handling both string and object outputs diff --git a/src/settings/models/index.tsx b/src/settings/models/index.tsx index d44182490..c1f9480db 100644 --- a/src/settings/models/index.tsx +++ b/src/settings/models/index.tsx @@ -36,7 +36,7 @@ import { Switch } from '@/components/ui/switch' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import { useDatabase } from '@/contexts' import { createModel as createModelDAL, deleteModel, getAllModels, resetModelToDefault, updateModel } from '@/dal' -import { defaultModels } from '@/defaults/models' +import { defaultModels } from '@shared/defaults/models' import { isModelModified } from '@/defaults/utils' import { fetch } from '@/lib/fetch' import { useProxyFetchGetter } from '@/lib/proxy-fetch-context' diff --git a/src/types.ts b/src/types.ts index 2426b8864..bb7dbec4d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -11,6 +11,7 @@ import type { DrizzleQuery } from '@powersync/drizzle-driver' import type { InferSelectModel } from 'drizzle-orm' import { type PostHog } from 'posthog-js' import type { z } from 'zod' +import type { SharedModel } from '@shared/defaults/models' import type { HttpClient } from './contexts' import type { AnyDrizzleDatabase } from './db/database-interface' import type { @@ -101,6 +102,16 @@ export type Model = WithRequired< | 'startWithReasoning' | 'supportsParallelToolCalls' > & { apiKey: string | null } + +/** + * Compile-time invariant: every shipped `SharedModel` default (see + * `shared/defaults/models.ts`) must be structurally assignable to `Model` with + * the DAL-joined `apiKey` stripped. If `Model` gains a required field that + * `SharedModel` doesn't cover, the assignment below errors, forcing us to + * update the shared type in the same change. Never called at runtime; exported + * so `noUnusedLocals` doesn't strip the guard. + */ +export const _sharedModelIsSubsetOfModel = (model: SharedModel): Omit => model export type Mode = WithRequired export type Task = WithRequired export type McpServer = WithRequired