From a6422eff9cea9db22af6c977ae8600b02ccfdc10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=AD=20Adriano?= Date: Thu, 2 Jul 2026 08:28:20 -0300 Subject: [PATCH 01/21] refactor: move models defaults to shared --- {src => shared}/defaults/models.ts | 62 +++++++++++++++++++------ src/ai/eval/debug-single.ts | 2 +- src/ai/eval/scenarios.ts | 2 +- src/dal/model-profiles.test.ts | 2 +- src/dal/models.test.ts | 2 +- src/dal/models.ts | 2 +- src/dal/prompts.test.ts | 2 +- src/defaults/automations.ts | 2 +- src/defaults/index.ts | 2 +- src/defaults/model-profiles/deepseek.ts | 2 +- src/defaults/model-profiles/glm.ts | 2 +- src/defaults/model-profiles/kimi.ts | 2 +- src/defaults/model-profiles/opus.ts | 2 +- src/defaults/utils.ts | 2 +- src/lib/defaults.test.ts | 2 +- src/lib/reconcile-defaults.test.ts | 2 +- src/lib/reconcile-defaults.ts | 2 +- src/settings/models/index.tsx | 2 +- 18 files changed, 65 insertions(+), 31 deletions(-) rename {src => shared}/defaults/models.ts (63%) diff --git a/src/defaults/models.ts b/shared/defaults/models.ts similarity index 63% rename from src/defaults/models.ts rename to shared/defaults/models.ts index a667ca798..3f74ef69b 100644 --- a/src/defaults/models.ts +++ b/shared/defaults/models.ts @@ -2,14 +2,49 @@ * 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' +/** + * Shape of a shipped model default. Structurally compatible with the + * frontend `Model` type (`src/types.ts`) so consumers can pass these values + * anywhere a `Model` is expected. Kept self-contained so this file has no + * dependency on frontend code and can be imported by backend and shared code. + */ +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 + apiKey: string | null +} + +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) +} /** - * Compute hash of user-editable fields for a model - * Includes deletedAt to treat soft-delete as a user configuration choice + * 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 => { +export const hashModel = (model: SharedModel): string => { return hashValues([ model.name, model.provider, @@ -27,17 +62,16 @@ export const hashModel = (model: Model): string => { } /** - * 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 + * 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 = { +export const defaultModelOpus48: SharedModel = { id: '019af08a-c27b-7074-8aac-95315d1ef3fd', name: 'Opus 4.8', provider: 'thunderbolt', @@ -58,7 +92,7 @@ export const defaultModelOpus48: Model = { userId: null, } -export const defaultModelDeepseekV4Pro: Model = { +export const defaultModelDeepseekV4Pro: SharedModel = { id: '019e70af-e5b2-76d0-9ede-f22d8265bb14', name: 'DeepSeek V4 Pro', provider: 'tinfoil', @@ -79,7 +113,7 @@ export const defaultModelDeepseekV4Pro: Model = { userId: null, } -export const defaultModelKimiK26: Model = { +export const defaultModelKimiK26: SharedModel = { id: '019e7580-2b0c-77d6-8b99-16a99abe4591', name: 'Kimi K2.6', provider: 'tinfoil', @@ -100,7 +134,7 @@ export const defaultModelKimiK26: Model = { userId: null, } -export const defaultModelGlm52: Model = { +export const defaultModelGlm52: SharedModel = { id: '019e7580-2b0e-719c-a43f-d2b56e7f31b4', name: 'GLM 5.2', provider: 'tinfoil', @@ -125,7 +159,7 @@ export const defaultModelGlm52: Model = { * Array of all default models for iteration. Order = display order in the * "Provided" group of the model picker. Reorder freely. */ -export const defaultModels: ReadonlyArray = [ +export const defaultModels: ReadonlyArray = [ defaultModelOpus48, defaultModelDeepseekV4Pro, defaultModelKimiK26, 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/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..619c99e14 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 } from '@shared/defaults/models' import { clearNullableColumns, nowIso } from '../lib/utils' import type { DrizzleQueryWithPromise, Model } from '@/types' import { getLastMessage } from './chat-messages' 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/deepseek.ts b/src/defaults/model-profiles/deepseek.ts index 2bfcc1e31..025ec68bf 100644 --- a/src/defaults/model-profiles/deepseek.ts +++ b/src/defaults/model-profiles/deepseek.ts @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import type { ModelProfile } from '@/types' -import { defaultModelDeepseekV4Pro } from '@/defaults/models' +import { defaultModelDeepseekV4Pro } from '@shared/defaults/models' export const defaultModelProfileDeepseekV4Pro: ModelProfile = { modelId: defaultModelDeepseekV4Pro.id, 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/kimi.ts b/src/defaults/model-profiles/kimi.ts index 55bc4635c..4c2a9b358 100644 --- a/src/defaults/model-profiles/kimi.ts +++ b/src/defaults/model-profiles/kimi.ts @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import type { ModelProfile } from '@/types' -import { defaultModelKimiK26 } from '@/defaults/models' +import { defaultModelKimiK26 } from '@shared/defaults/models' export const defaultModelProfileKimiK26: ModelProfile = { modelId: defaultModelKimiK26.id, 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/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/lib/defaults.test.ts b/src/lib/defaults.test.ts index f19c15f29..5fbdf5f9d 100644 --- a/src/lib/defaults.test.ts +++ b/src/lib/defaults.test.ts @@ -5,7 +5,7 @@ import type { Model } from '@/types' import { describe, expect, test } from 'bun:test' import { defaultAutomations, hashPrompt } from '../defaults/automations' -import { defaultModels, hashModel } from '../defaults/models' +import { defaultModels, hashModel } from '@shared/defaults/models' import { defaultSettings, hashSetting } from '../defaults/settings' describe('defaults', () => { diff --git a/src/lib/reconcile-defaults.test.ts b/src/lib/reconcile-defaults.test.ts index 4c5e51df9..5eb580ae0 100644 --- a/src/lib/reconcile-defaults.test.ts +++ b/src/lib/reconcile-defaults.test.ts @@ -10,7 +10,7 @@ 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 { defaultModels, hashModel } from '@shared/defaults/models' import { defaultSettings, hashSetting } from '../defaults/settings' import { nowIso } from './utils' import { cleanupRemovedDefaults, reconcileDefaultsForTable } from './reconcile-defaults' diff --git a/src/lib/reconcile-defaults.ts b/src/lib/reconcile-defaults.ts index 2ae0bf410..e979cc60c 100644 --- a/src/lib/reconcile-defaults.ts +++ b/src/lib/reconcile-defaults.ts @@ -11,7 +11,7 @@ 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, hashModel } from '@shared/defaults/models' import { defaultSettings, hashSetting } from '../defaults/settings' import { defaultSkills, hashSkill } from '../defaults/skills' import { defaultTasks, hashTask } from '../defaults/tasks' 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' From 3773a5ac1ed0319cd5f3fed1a739962dae51dea2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=AD=20Adriano?= Date: Thu, 2 Jul 2026 08:34:44 -0300 Subject: [PATCH 02/21] feat: version and snapshot-lock models defaults --- AGENTS.md | 6 ++++++ shared/defaults/models.test.ts | 35 ++++++++++++++++++++++++++++++++++ shared/defaults/models.ts | 15 ++++++++++++++- 3 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 shared/defaults/models.test.ts 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/shared/defaults/models.test.ts b/shared/defaults/models.test.ts new file mode 100644 index 000000000..9f69de5b4 --- /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: 1, + hash: '0:019af08a-c27b-7074-8aac-95315d1ef3fd:-1vf2pk|1:019e70af-e5b2-76d0-9ede-f22d8265bb14:i4q5q9|2:019e7580-2b0c-77d6-8b99-16a99abe4591:b3y2hj|3: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 index 3f74ef69b..16f11037d 100644 --- a/shared/defaults/models.ts +++ b/shared/defaults/models.ts @@ -157,7 +157,8 @@ export const defaultModelGlm52: SharedModel = { /** * Array of all default models for iteration. Order = display order in the - * "Provided" group of the model picker. Reorder freely. + * "Provided" group of the model picker. Reorder freely — but bump + * `defaultModelsVersion` when you do. */ export const defaultModels: ReadonlyArray = [ defaultModelOpus48, @@ -165,3 +166,15 @@ export const defaultModels: ReadonlyArray = [ defaultModelKimiK26, 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 = 1 From f8ffa070c171a7482a7345ab124d5816842dcde0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=AD=20Adriano?= Date: Thu, 2 Jul 2026 08:47:08 -0300 Subject: [PATCH 03/21] fix: version-gate models reconcile against downgrades --- src/lib/reconcile-defaults.ts | 75 +++++++++++++++++++++++++++++------ 1 file changed, 63 insertions(+), 12 deletions(-) diff --git a/src/lib/reconcile-defaults.ts b/src/lib/reconcile-defaults.ts index e979cc60c..feae86ad2 100644 --- a/src/lib/reconcile-defaults.ts +++ b/src/lib/reconcile-defaults.ts @@ -4,19 +4,41 @@ import type { AnyDrizzleDatabase } from '@/db/database-interface' import type { Model, ModelProfile } from '@/types' -import { createSetting } from '@/dal' +import { createSetting, updateSettings } from '@/dal' import { eq, inArray, isNull } from 'drizzle-orm' import type { SQLiteTableWithColumns } from 'drizzle-orm/sqlite-core' 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 '@shared/defaults/models' +import { defaultModels, defaultModelsVersion, hashModel } from '@shared/defaults/models' import { defaultSettings, hashSetting } from '../defaults/settings' import { defaultSkills, hashSkill } from '../defaults/skills' import { defaultTasks, hashTask } from '../defaults/tasks' import { nowIso } from './utils' +/** + * 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 + * null when no version has been recorded yet (fresh install / pre-versioning) + * or when the stored value is not a finite number (treat as "safe to apply"). + */ +const readAppliedVersion = async (db: AnyDrizzleDatabase, key: string): Promise => { + const rows = await db.select().from(settingsTable).where(eq(settingsTable.key, key)) + const raw = rows[0]?.value + if (raw == null) { + return null + } + const parsed = Number(raw) + return Number.isFinite(parsed) ? parsed : null +} + /** * Generic function to reconcile defaults into a table * Inserts new defaults and updates unmodified existing ones @@ -27,6 +49,10 @@ import { nowIso } from './utils' * @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') + * @param canOverwrite - When false, skip in-place updates of existing rows + * (still inserts missing rows and stamps defaultHash on legacy null rows). + * Set to false when the caller's defaults version is older than what has + * already been applied on this account (see THU-637 / AGENTS.md). */ export const reconcileDefaultsForTable = async ( db: AnyDrizzleDatabase, @@ -34,6 +60,7 @@ export const reconcileDefaultsForTable = async string, keyField: string = 'id', + canOverwrite: boolean = true, ) => { if (defaults.length === 0) { return @@ -87,6 +114,12 @@ export const reconcileDefaultsForTable = async { +export const cleanupRemovedDefaults = async (db: AnyDrizzleDatabase, canOverwriteModels: boolean = true) => { const now = nowIso() const currentModelIds = new Set(defaultModels.map((m) => m.id)) @@ -114,13 +153,15 @@ 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) + } } } @@ -145,11 +186,17 @@ export const cleanupRemovedDefaults = async (db: AnyDrizzleDatabase) => { export const reconcileDefaults = async (db: AnyDrizzleDatabase) => { 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 storedModelsVersion = await readAppliedVersion(tx, modelsVersionKey) + const canOverwriteModels = defaultModelsVersion > (storedModelsVersion ?? Number.NEGATIVE_INFINITY) + // Soft-delete removed system defaults before reconciling current ones. - await cleanupRemovedDefaults(tx) + await cleanupRemovedDefaults(tx, canOverwriteModels) // AI models - await reconcileDefaultsForTable(tx, modelsTable, defaultModels, hashModel) + await reconcileDefaultsForTable(tx, modelsTable, defaultModels, hashModel, 'id', canOverwriteModels) // Model profiles (after models, because they reference model IDs) await reconcileDefaultsForTable(tx, modelProfilesTable, defaultModelProfiles, hashModelProfile, 'modelId') @@ -167,6 +214,10 @@ export const reconcileDefaults = async (db: AnyDrizzleDatabase) => { // Settings await reconcileDefaultsForTable(tx, settingsTable, defaultSettings, hashSetting, 'key') + if (canOverwriteModels) { + await updateSettings(tx, { [modelsVersionKey]: defaultModelsVersion }) + } + // Initialize anonymous ID for analytics (unique per user) await createSetting(tx, 'anonymous_id', uuidv7()) }) From bc5d026d2bbbbd5f5be4e38b61a81001a0666d6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=AD=20Adriano?= Date: Thu, 2 Jul 2026 08:55:55 -0300 Subject: [PATCH 04/21] test: cover THU-637 downgrade regression --- src/lib/reconcile-defaults.test.ts | 138 ++++++++++++++++++++++++++++- 1 file changed, 136 insertions(+), 2 deletions(-) diff --git a/src/lib/reconcile-defaults.test.ts b/src/lib/reconcile-defaults.test.ts index 5eb580ae0..d1b25b9aa 100644 --- a/src/lib/reconcile-defaults.test.ts +++ b/src/lib/reconcile-defaults.test.ts @@ -10,10 +10,10 @@ 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 '@shared/defaults/models' +import { defaultModels, defaultModelsVersion, hashModel } from '@shared/defaults/models' import { defaultSettings, hashSetting } from '../defaults/settings' import { nowIso } from './utils' -import { cleanupRemovedDefaults, reconcileDefaultsForTable } from './reconcile-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. */ @@ -520,6 +520,24 @@ describe('reconcileDefaultsForTable', () => { expect(settings.length).toBe(0) }) + 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: Model = { ...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, 'id', 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() @@ -554,3 +572,119 @@ describe('reconcileDefaultsForTable', () => { 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: Model = { ...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') + }) +}) From d9bb8e6e83b8b1a35ebb5d575a2b877a56da09ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=AD=20Adriano?= Date: Thu, 2 Jul 2026 09:01:23 -0300 Subject: [PATCH 05/21] feat(backend): serve models defaults via /config --- backend/src/api/config.test.ts | 7 +++++++ backend/src/api/config.ts | 12 ++++++++++++ 2 files changed, 19 insertions(+) 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, + }, + }, })) From e36ce58a88040e0d97c44910f14a7fd45d0b0d29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=AD=20Adriano?= Date: Thu, 2 Jul 2026 09:07:39 -0300 Subject: [PATCH 06/21] feat: prefer server models defaults when newer --- src/api/config-store.ts | 10 ++++++++ src/hooks/use-app-initialization.ts | 17 ++++++++++--- src/lib/pick-defaults.test.ts | 39 +++++++++++++++++++++++++++++ src/lib/pick-defaults.ts | 29 +++++++++++++++++++++ src/lib/reconcile-defaults.ts | 31 +++++++++++++++++------ 5 files changed, 114 insertions(+), 12 deletions(-) create mode 100644 src/lib/pick-defaults.test.ts create mode 100644 src/lib/pick-defaults.ts 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/hooks/use-app-initialization.ts b/src/hooks/use-app-initialization.ts index 618febebf..908031e19 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,17 @@ 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. fetchConfig + // has an internal 5s timeout and never rejects, so this is bounded; when + // offline, the persisted store value from the last successful fetch is used + // (or the bundle wins on a fresh install with no cached config). + await fetchConfigPromise + const modelsDefaults = pickModelsDefaults(useConfigStore.getState().config.defaults?.models) + // Step 4: Reconcile defaults try { - await time('step4_reconcile_defaults', () => reconcileDefaults(db)) + await time('step4_reconcile_defaults', () => reconcileDefaults(db, { models: modelsDefaults })) } catch (error) { console.error('Failed to reconcile default settings:', error) const reconcileError = createHandleError('RECONCILE_DEFAULTS_FAILED', 'Failed to reconcile default settings', error) @@ -214,9 +224,8 @@ const executeInitializationSteps = async (httpClient?: HttpClient): Promise ({ + 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) + }) +}) diff --git a/src/lib/pick-defaults.ts b/src/lib/pick-defaults.ts new file mode 100644 index 000000000..758e1c767 --- /dev/null +++ b/src/lib/pick-defaults.ts @@ -0,0 +1,29 @@ +/* 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. + */ +export const pickModelsDefaults = (server: ServerModelsDefaults | undefined): ModelsDefaults => { + if (server && server.version > defaultModelsVersion) { + return { version: server.version, data: server.data } + } + return { version: defaultModelsVersion, data: defaultModels } +} diff --git a/src/lib/reconcile-defaults.ts b/src/lib/reconcile-defaults.ts index feae86ad2..bfc985dd8 100644 --- a/src/lib/reconcile-defaults.ts +++ b/src/lib/reconcile-defaults.ts @@ -11,12 +11,15 @@ 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, defaultModelsVersion, hashModel } from '@shared/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 @@ -142,9 +145,13 @@ export const reconcileDefaultsForTable = async { +export const cleanupRemovedDefaults = async ( + db: AnyDrizzleDatabase, + canOverwriteModels: boolean = true, + models: readonly SharedModel[] = defaultModels, +) => { 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 @@ -184,19 +191,27 @@ export const cleanupRemovedDefaults = async (db: AnyDrizzleDatabase, canOverwrit } } -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 +} + +export const reconcileDefaults = async (db: AnyDrizzleDatabase, overrides?: ReconcileDefaultsOverrides) => { + const modelsSource = overrides?.models ?? bundledModelsDefaults + 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 storedModelsVersion = await readAppliedVersion(tx, modelsVersionKey) - const canOverwriteModels = defaultModelsVersion > (storedModelsVersion ?? Number.NEGATIVE_INFINITY) + const canOverwriteModels = modelsSource.version > (storedModelsVersion ?? Number.NEGATIVE_INFINITY) // Soft-delete removed system defaults before reconciling current ones. - await cleanupRemovedDefaults(tx, canOverwriteModels) + await cleanupRemovedDefaults(tx, canOverwriteModels, modelsSource.data) // AI models - await reconcileDefaultsForTable(tx, modelsTable, defaultModels, hashModel, 'id', canOverwriteModels) + await reconcileDefaultsForTable(tx, modelsTable, modelsSource.data, hashModel, 'id', canOverwriteModels) // Model profiles (after models, because they reference model IDs) await reconcileDefaultsForTable(tx, modelProfilesTable, defaultModelProfiles, hashModelProfile, 'modelId') @@ -215,7 +230,7 @@ export const reconcileDefaults = async (db: AnyDrizzleDatabase) => { await reconcileDefaultsForTable(tx, settingsTable, defaultSettings, hashSetting, 'key') if (canOverwriteModels) { - await updateSettings(tx, { [modelsVersionKey]: defaultModelsVersion }) + await updateSettings(tx, { [modelsVersionKey]: modelsSource.version }) } // Initialize anonymous ID for analytics (unique per user) From 6534157b9592871c057c32bb965365c92d536e04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=AD=20Adriano?= Date: Thu, 2 Jul 2026 09:42:29 -0300 Subject: [PATCH 07/21] fix: inline version upsert to avoid nested transaction --- src/lib/reconcile-defaults.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/lib/reconcile-defaults.ts b/src/lib/reconcile-defaults.ts index bfc985dd8..ee06103e2 100644 --- a/src/lib/reconcile-defaults.ts +++ b/src/lib/reconcile-defaults.ts @@ -4,7 +4,7 @@ import type { AnyDrizzleDatabase } from '@/db/database-interface' import type { Model, ModelProfile } from '@/types' -import { createSetting, updateSettings } from '@/dal' +import { createSetting } from '@/dal' import { eq, inArray, isNull } from 'drizzle-orm' import type { SQLiteTableWithColumns } from 'drizzle-orm/sqlite-core' import { v7 as uuidv7 } from 'uuid' @@ -230,7 +230,15 @@ export const reconcileDefaults = async (db: AnyDrizzleDatabase, overrides?: Reco await reconcileDefaultsForTable(tx, settingsTable, defaultSettings, hashSetting, 'key') if (canOverwriteModels) { - await updateSettings(tx, { [modelsVersionKey]: modelsSource.version }) + // Inline upsert: `updateSettings` wraps its writes in its own transaction + // and PowerSync's drizzle driver forbids nested transactions. We already + // know from `readAppliedVersion` whether the row exists, so branch here. + const versionValue = String(modelsSource.version) + if (storedModelsVersion === null) { + 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) From 876ad4e543f605b99dbfa205c0478aa99c7f210a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=AD=20Adriano?= Date: Thu, 2 Jul 2026 09:42:42 -0300 Subject: [PATCH 08/21] fix(backend): keep pglite in-memory when DATABASE_URL is a connection string --- backend/src/db/client.ts | 27 ++++++++++++++++++++++++--- backend/src/test-utils/test-setup.ts | 2 ++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/backend/src/db/client.ts b/backend/src/db/client.ts index 27d1f6789..d82656992 100644 --- a/backend/src/db/client.ts +++ b/backend/src/db/client.ts @@ -26,11 +26,32 @@ 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 + +if (pgliteDataDir) { + mkdirSync(resolve(pgliteDataDir), { recursive: true }) } -const pgliteClient = isPglite ? new PGlite(process.env.DATABASE_URL) : null // undefined = in-memory +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/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 From 11dfddafcf74b8010403cab2519f2a701d31a150 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=AD=20Adriano?= Date: Thu, 2 Jul 2026 09:42:50 -0300 Subject: [PATCH 09/21] fix(tauri): allow localhost:8080 in CSP connect-src --- src-tauri/tauri.conf.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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'", From 1b4d4ade97585ddb7b40b26b9002f5bcb7718b24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=AD=20Adriano?= Date: Thu, 2 Jul 2026 11:49:27 -0300 Subject: [PATCH 10/21] =?UTF-8?q?fix:=20address=20bugbot=20findings=20?= =?UTF-8?q?=E2=80=94=20empty=20OTA=20guard,=20version-key=20upsert,=20prof?= =?UTF-8?q?iles=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/pick-defaults.test.ts | 18 +++++++++++ src/lib/pick-defaults.ts | 8 ++++- src/lib/reconcile-defaults.test.ts | 51 +++++++++++++++++++++++++++++ src/lib/reconcile-defaults.ts | 52 +++++++++++++++++++++--------- 4 files changed, 113 insertions(+), 16 deletions(-) diff --git a/src/lib/pick-defaults.test.ts b/src/lib/pick-defaults.test.ts index 84ead4eab..918932ca0 100644 --- a/src/lib/pick-defaults.test.ts +++ b/src/lib/pick-defaults.test.ts @@ -36,4 +36,22 @@ describe('pickModelsDefaults', () => { 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) + }) }) diff --git a/src/lib/pick-defaults.ts b/src/lib/pick-defaults.ts index 758e1c767..4366a694a 100644 --- a/src/lib/pick-defaults.ts +++ b/src/lib/pick-defaults.ts @@ -20,9 +20,15 @@ type ServerModelsDefaults = { version: number; data: SharedModel[] } * 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. + * + * A server payload with a bumped version but missing / non-array / empty `data` + * is treated as malformed and rejected — otherwise `cleanupRemovedDefaults` + * would soft-delete every unedited system model on the way past (empty + * `currentModelIds` matches nothing) and the stored version would advance, + * making local recovery impossible short of another OTA. */ export const pickModelsDefaults = (server: ServerModelsDefaults | undefined): ModelsDefaults => { - if (server && server.version > defaultModelsVersion) { + if (server && server.version > defaultModelsVersion && Array.isArray(server.data) && server.data.length > 0) { return { version: server.version, data: server.data } } return { version: defaultModelsVersion, data: defaultModels } diff --git a/src/lib/reconcile-defaults.test.ts b/src/lib/reconcile-defaults.test.ts index d1b25b9aa..8839fa52f 100644 --- a/src/lib/reconcile-defaults.test.ts +++ b/src/lib/reconcile-defaults.test.ts @@ -687,4 +687,55 @@ describe('reconcileDefaults version gate (THU-637)', () => { 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 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) + }) }) diff --git a/src/lib/reconcile-defaults.ts b/src/lib/reconcile-defaults.ts index ee06103e2..2788bc855 100644 --- a/src/lib/reconcile-defaults.ts +++ b/src/lib/reconcile-defaults.ts @@ -29,17 +29,25 @@ const modelsVersionKey = 'defaults_version.models' /** * Read a previously-recorded defaults version from `settingsTable`. Returns - * null when no version has been recorded yet (fresh install / pre-versioning) - * or when the stored value is not a finite number (treat as "safe to apply"). + * `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 => { +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 raw = rows[0]?.value - if (raw == null) { - return null + const row = rows[0] + if (!row) { + return { exists: false, version: null } + } + if (row.value == null) { + return { exists: true, version: null } } - const parsed = Number(raw) - return Number.isFinite(parsed) ? parsed : null + const parsed = Number(row.value) + return { exists: true, version: Number.isFinite(parsed) ? parsed : null } } /** @@ -204,8 +212,8 @@ export const reconcileDefaults = async (db: AnyDrizzleDatabase, overrides?: Reco // 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 storedModelsVersion = await readAppliedVersion(tx, modelsVersionKey) - const canOverwriteModels = modelsSource.version > (storedModelsVersion ?? Number.NEGATIVE_INFINITY) + const stored = await readAppliedVersion(tx, modelsVersionKey) + const canOverwriteModels = modelsSource.version > (stored.version ?? Number.NEGATIVE_INFINITY) // Soft-delete removed system defaults before reconciling current ones. await cleanupRemovedDefaults(tx, canOverwriteModels, modelsSource.data) @@ -213,8 +221,20 @@ export const reconcileDefaults = async (db: AnyDrizzleDatabase, overrides?: Reco // AI models await reconcileDefaultsForTable(tx, modelsTable, modelsSource.data, hashModel, 'id', canOverwriteModels) - // 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 gate — 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. Insert-of-missing still runs regardless, so orphaned profiles are + // impossible even when overwrites are skipped. + await reconcileDefaultsForTable( + tx, + modelProfilesTable, + defaultModelProfiles, + hashModelProfile, + 'modelId', + canOverwriteModels, + ) // Modes await reconcileDefaultsForTable(tx, modesTable, defaultModes, hashMode) @@ -231,10 +251,12 @@ export const reconcileDefaults = async (db: AnyDrizzleDatabase, overrides?: Reco if (canOverwriteModels) { // Inline upsert: `updateSettings` wraps its writes in its own transaction - // and PowerSync's drizzle driver forbids nested transactions. We already - // know from `readAppliedVersion` whether the row exists, so branch here. + // 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 (storedModelsVersion === null) { + 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)) From da9f006a164cd042e78b0d6026fbf885f86a8fbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=AD=20Adriano?= Date: Mon, 6 Jul 2026 09:47:04 -0300 Subject: [PATCH 11/21] fix: harden model reconcile against sync races --- src/hooks/use-app-initialization.ts | 5 ++- src/lib/reconcile-defaults.test.ts | 57 +++++++++++++++++++++++++++++ src/lib/reconcile-defaults.ts | 57 +++++++++++++++++++++++------ 3 files changed, 106 insertions(+), 13 deletions(-) diff --git a/src/hooks/use-app-initialization.ts b/src/hooks/use-app-initialization.ts index 908031e19..1f77a8606 100644 --- a/src/hooks/use-app-initialization.ts +++ b/src/hooks/use-app-initialization.ts @@ -168,10 +168,13 @@ const executeInitializationSteps = async (httpClient?: HttpClient): Promise reconcileDefaults(db, { models: modelsDefaults })) + 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) diff --git a/src/lib/reconcile-defaults.test.ts b/src/lib/reconcile-defaults.test.ts index 8839fa52f..b2adbb5c5 100644 --- a/src/lib/reconcile-defaults.test.ts +++ b/src/lib/reconcile-defaults.test.ts @@ -701,6 +701,63 @@ describe('reconcileDefaults version gate (THU-637)', () => { 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('older bundle does not overwrite profiles authored by a newer version', async () => { const db = getDb() diff --git a/src/lib/reconcile-defaults.ts b/src/lib/reconcile-defaults.ts index 2788bc855..9fd15223f 100644 --- a/src/lib/reconcile-defaults.ts +++ b/src/lib/reconcile-defaults.ts @@ -60,10 +60,13 @@ const readAppliedVersion = async ( * @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') - * @param canOverwrite - When false, skip in-place updates of existing rows - * (still inserts missing rows and stamps defaultHash on legacy null rows). - * Set to false when the caller's defaults version is older than what has - * already been applied on this account (see THU-637 / AGENTS.md). + * @param canOverwrite - When false, this pass is purely non-mutating for the + * table: no inserts of missing rows, no bootstrap of legacy null defaultHash, + * no updates. 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. Prevents ghost-inserting rows that a newer version deliberately + * removed but hasn't finished syncing to us (see THU-637 / AGENTS.md). */ export const reconcileDefaultsForTable = async ( db: AnyDrizzleDatabase, @@ -86,7 +89,12 @@ export const reconcileDefaultsForTable = async { 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 canOverwriteModels = modelsSource.version > (stored.version ?? Number.NEGATIVE_INFINITY) + const rawCanOverwrite = modelsSource.version > (stored.version ?? Number.NEGATIVE_INFINITY) + + // Additional guard for the fresh-second-device / sync-timeout case: when + // the initial sync didn't complete AND we have no local stored version, + // cloud may hold both the version marker AND newer rows we haven't + // received. Acting on partial state would let us regress the marker or + // ghost-insert defaults the newer version has retired. 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 rawCanOverwrite + } + if (!stored.exists) { + return false + } + return rawCanOverwrite + })() // Soft-delete removed system defaults before reconciling current ones. await cleanupRemovedDefaults(tx, canOverwriteModels, modelsSource.data) From 63dfaa3c092ae5790ab67f8f8cd1b3a142bde239 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=AD=20Adriano?= Date: Mon, 6 Jul 2026 09:51:35 -0300 Subject: [PATCH 12/21] =?UTF-8?q?chore:=20harden=20shared/=20boundary=20?= =?UTF-8?q?=E2=80=94=20extract=20hashValues,=20drop=20apiKey,=20enforce=20?= =?UTF-8?q?model=20compat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shared/defaults/models.ts | 31 +++++++++++------------------- shared/lib/hash.ts | 25 ++++++++++++++++++++++++ src/dal/models.ts | 10 +++++++--- src/lib/defaults.test.ts | 6 ++---- src/lib/reconcile-defaults.test.ts | 6 +++--- src/lib/utils.ts | 19 +++++------------- src/types.ts | 11 +++++++++++ 7 files changed, 64 insertions(+), 44 deletions(-) create mode 100644 shared/lib/hash.ts diff --git a/shared/defaults/models.ts b/shared/defaults/models.ts index 16f11037d..43a1feeec 100644 --- a/shared/defaults/models.ts +++ b/shared/defaults/models.ts @@ -2,11 +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 { hashValues } from '@shared/lib/hash' + /** - * Shape of a shipped model default. Structurally compatible with the - * frontend `Model` type (`src/types.ts`) so consumers can pass these values - * anywhere a `Model` is expected. Kept self-contained so this file has no - * dependency on frontend code and can be imported by backend and shared code. + * 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 @@ -26,18 +33,6 @@ export type SharedModel = { vendor: string | null description: string | null userId: string | null - apiKey: string | null -} - -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) } /** @@ -84,7 +79,6 @@ export const defaultModelOpus48: SharedModel = { startWithReasoning: 0, supportsParallelToolCalls: 1, deletedAt: null, - apiKey: null, url: null, defaultHash: null, vendor: 'anthropic', @@ -105,7 +99,6 @@ export const defaultModelDeepseekV4Pro: SharedModel = { startWithReasoning: 0, supportsParallelToolCalls: 0, deletedAt: null, - apiKey: null, url: null, defaultHash: null, vendor: 'deepseek', @@ -126,7 +119,6 @@ export const defaultModelKimiK26: SharedModel = { startWithReasoning: 0, supportsParallelToolCalls: 0, deletedAt: null, - apiKey: null, url: null, defaultHash: null, vendor: 'moonshot', @@ -147,7 +139,6 @@ export const defaultModelGlm52: SharedModel = { startWithReasoning: 0, supportsParallelToolCalls: 0, deletedAt: null, - apiKey: null, url: null, defaultHash: null, vendor: 'zhipu', 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/dal/models.ts b/src/dal/models.ts index 619c99e14..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 '@shared/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/lib/defaults.test.ts b/src/lib/defaults.test.ts index 5fbdf5f9d..343f80c62 100644 --- a/src/lib/defaults.test.ts +++ b/src/lib/defaults.test.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 type { Model } from '@/types' +import type { SharedModel } from '@shared/defaults/models' import { describe, expect, test } from 'bun:test' import { defaultAutomations, hashPrompt } from '../defaults/automations' import { defaultModels, hashModel } from '@shared/defaults/models' @@ -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/reconcile-defaults.test.ts b/src/lib/reconcile-defaults.test.ts index b2adbb5c5..b4502ab2d 100644 --- a/src/lib/reconcile-defaults.test.ts +++ b/src/lib/reconcile-defaults.test.ts @@ -10,7 +10,7 @@ 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, defaultModelsVersion, hashModel } from '@shared/defaults/models' +import { defaultModels, defaultModelsVersion, hashModel, type SharedModel } from '@shared/defaults/models' import { defaultSettings, hashSetting } from '../defaults/settings' import { nowIso } from './utils' import { cleanupRemovedDefaults, reconcileDefaults, reconcileDefaultsForTable } from './reconcile-defaults' @@ -527,7 +527,7 @@ describe('reconcileDefaultsForTable', () => { // 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: Model = { ...bundleRow, name: 'Newer Bundle Name' } + 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. @@ -605,7 +605,7 @@ describe('reconcileDefaults version gate (THU-637)', () => { // 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: Model = { ...bundleRow, name: 'Newer Bundle Name', description: 'from newer' } + 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) }) 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/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 From 4a277a16a994591d6e1621cc9d2f83749bf22c82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=AD=20Adriano?= Date: Mon, 6 Jul 2026 09:53:06 -0300 Subject: [PATCH 13/21] refactor: options object for reconcileDefaultsForTable --- src/lib/reconcile-defaults.test.ts | 24 +++++++-------- src/lib/reconcile-defaults.ts | 47 +++++++++++++++++------------- 2 files changed, 38 insertions(+), 33 deletions(-) diff --git a/src/lib/reconcile-defaults.test.ts b/src/lib/reconcile-defaults.test.ts index b4502ab2d..0fbdb2e31 100644 --- a/src/lib/reconcile-defaults.test.ts +++ b/src/lib/reconcile-defaults.test.ts @@ -352,7 +352,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 +369,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 +391,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 +401,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 +412,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 +420,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 +454,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 +503,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,7 +514,7 @@ 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) @@ -531,7 +531,7 @@ describe('reconcileDefaultsForTable', () => { 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, 'id', false) + 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') @@ -565,7 +565,7 @@ 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() diff --git a/src/lib/reconcile-defaults.ts b/src/lib/reconcile-defaults.ts index 9fd15223f..c29f3d7d6 100644 --- a/src/lib/reconcile-defaults.ts +++ b/src/lib/reconcile-defaults.ts @@ -51,16 +51,10 @@ const readAppliedVersion = async ( } /** - * Generic function to reconcile defaults into a table - * Inserts new defaults and updates unmodified existing ones + * Options for `reconcileDefaultsForTable`. * - * 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') - * @param canOverwrite - When false, this pass is purely non-mutating for the + * @property keyField - Name of the primary key field. Defaults to `'id'`. + * @property canOverwrite - When false, this pass is purely non-mutating for the * table: no inserts of missing rows, no bootstrap of legacy null defaultHash, * no updates. Set to false when the caller's defaults source is not * authoritative — either strictly older than what has already been applied @@ -68,14 +62,27 @@ const readAppliedVersion = async ( * yet. Prevents ghost-inserting rows that a newer version deliberately * removed but hasn't finished syncing to us (see THU-637 / AGENTS.md). */ +export type ReconcileDefaultsForTableOptions = { + keyField?: string + canOverwrite?: boolean +} + +/** + * Generic function to reconcile defaults into a table + * 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. + */ export const reconcileDefaultsForTable = async ( db: AnyDrizzleDatabase, table: SQLiteTableWithColumns, defaults: readonly T[], hashFn: (item: any) => string, - keyField: string = 'id', - canOverwrite: boolean = true, + options: ReconcileDefaultsForTableOptions = {}, ) => { + const { keyField = 'id', canOverwrite = true } = options + if (defaults.length === 0) { return } @@ -252,7 +259,9 @@ export const reconcileDefaults = async (db: AnyDrizzleDatabase, overrides?: Reco await cleanupRemovedDefaults(tx, canOverwriteModels, modelsSource.data) // AI models - await reconcileDefaultsForTable(tx, modelsTable, modelsSource.data, hashModel, 'id', canOverwriteModels) + await reconcileDefaultsForTable(tx, modelsTable, modelsSource.data, hashModel, { + canOverwrite: canOverwriteModels, + }) // Model profiles ship 1:1 with models and mutate together in practice, so // they ride the same gate — otherwise an older-bundle device would revert @@ -260,14 +269,10 @@ export const reconcileDefaults = async (db: AnyDrizzleDatabase, overrides?: Reco // shipped alongside its model changes, reintroducing THU-637 on the profile // side. Insert-of-missing still runs regardless, so orphaned profiles are // impossible even when overwrites are skipped. - await reconcileDefaultsForTable( - tx, - modelProfilesTable, - defaultModelProfiles, - hashModelProfile, - 'modelId', - canOverwriteModels, - ) + await reconcileDefaultsForTable(tx, modelProfilesTable, defaultModelProfiles, hashModelProfile, { + keyField: 'modelId', + canOverwrite: canOverwriteModels, + }) // Modes await reconcileDefaultsForTable(tx, modesTable, defaultModes, hashMode) @@ -280,7 +285,7 @@ export const reconcileDefaults = async (db: AnyDrizzleDatabase, overrides?: Reco await reconcileDefaultsForTable(tx, skillsTable, defaultSkills, hashSkill) // Settings - await reconcileDefaultsForTable(tx, settingsTable, defaultSettings, hashSetting, 'key') + await reconcileDefaultsForTable(tx, settingsTable, defaultSettings, hashSetting, { keyField: 'key' }) if (canOverwriteModels) { // Inline upsert: `updateSettings` wraps its writes in its own transaction From e28fefd0fca8d58433e826a8f26953366d0f199e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=AD=20Adriano?= Date: Mon, 6 Jul 2026 09:54:54 -0300 Subject: [PATCH 14/21] fix(ci): pick up shared subdirs in test globs, camelCase constants --- package.json | 4 ++-- shared/defaults/models.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) 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 index 9f69de5b4..a08d9b5e7 100644 --- a/shared/defaults/models.test.ts +++ b/shared/defaults/models.test.ts @@ -20,7 +20,7 @@ import { defaultModels, defaultModelsVersion, hashModel } from './models' const computeSnapshotHash = () => defaultModels.map((model, index) => `${index}:${model.id}:${hashModel(model)}`).join('|') -const EXPECTED = { +const expected = { version: 1, hash: '0:019af08a-c27b-7074-8aac-95315d1ef3fd:-1vf2pk|1:019e70af-e5b2-76d0-9ede-f22d8265bb14:i4q5q9|2:019e7580-2b0c-77d6-8b99-16a99abe4591:b3y2hj|3:019e7580-2b0e-719c-a43f-d2b56e7f31b4:-g7x2jr', } @@ -30,6 +30,6 @@ describe('defaultModels version snapshot', () => { expect({ version: defaultModelsVersion, hash: computeSnapshotHash(), - }).toEqual(EXPECTED) + }).toEqual(expected) }) }) From b8f07466f747525406f1eb7adf290d470660295c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=AD=20Adriano?= Date: Mon, 6 Jul 2026 09:56:40 -0300 Subject: [PATCH 15/21] chore: cache short-circuit, stricter picker guard, pglite fallback warn --- backend/src/db/client.ts | 8 ++++++++ src/hooks/use-app-initialization.ts | 15 ++++++++++----- src/lib/pick-defaults.test.ts | 10 ++++++++++ src/lib/pick-defaults.ts | 8 +++++++- 4 files changed, 35 insertions(+), 6 deletions(-) diff --git a/backend/src/db/client.ts b/backend/src/db/client.ts index d82656992..08996f91f 100644 --- a/backend/src/db/client.ts +++ b/backend/src/db/client.ts @@ -43,6 +43,14 @@ const pgliteDataDir = ? process.env.DATABASE_URL : undefined +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 ` + + `(${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 }) } diff --git a/src/hooks/use-app-initialization.ts b/src/hooks/use-app-initialization.ts index 1f77a8606..47b4409ce 100644 --- a/src/hooks/use-app-initialization.ts +++ b/src/hooks/use-app-initialization.ts @@ -162,11 +162,16 @@ 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. fetchConfig - // has an internal 5s timeout and never rejects, so this is bounded; when - // offline, the persisted store value from the last successful fetch is used - // (or the bundle wins on a fresh install with no cached config). - await fetchConfigPromise + // defaults when they declare a higher version than the bundle. Skip the + // await when the persisted store already has a defaults payload from a prior + // successful fetch — the value is used immediately and the in-flight + // fetchConfigPromise resolves in the background for future launches. This + // avoids serializing reconcile behind up to 5 s of network wait on the happy + // (cache-hit) path. First-ever launch (no persisted payload) still awaits. + const persistedModelsDefaults = useConfigStore.getState().config.defaults?.models + if (!persistedModelsDefaults) { + await fetchConfigPromise + } const modelsDefaults = pickModelsDefaults(useConfigStore.getState().config.defaults?.models) const initialSyncCompleted = initialSyncOutcome === 'synced' || initialSyncOutcome === 'disabled' diff --git a/src/lib/pick-defaults.test.ts b/src/lib/pick-defaults.test.ts index 918932ca0..d5385aa9c 100644 --- a/src/lib/pick-defaults.test.ts +++ b/src/lib/pick-defaults.test.ts @@ -54,4 +54,14 @@ describe('pickModelsDefaults', () => { 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) + } + }) }) diff --git a/src/lib/pick-defaults.ts b/src/lib/pick-defaults.ts index 4366a694a..fcc0b3b99 100644 --- a/src/lib/pick-defaults.ts +++ b/src/lib/pick-defaults.ts @@ -28,7 +28,13 @@ type ServerModelsDefaults = { version: number; data: SharedModel[] } * making local recovery impossible short of another OTA. */ export const pickModelsDefaults = (server: ServerModelsDefaults | undefined): ModelsDefaults => { - if (server && server.version > defaultModelsVersion && Array.isArray(server.data) && server.data.length > 0) { + if ( + server && + Number.isFinite(server.version) && + server.version > defaultModelsVersion && + Array.isArray(server.data) && + server.data.length > 0 + ) { return { version: server.version, data: server.data } } return { version: defaultModelsVersion, data: defaultModels } From cb7b4264574b40589d089ada5f7c1cf0bf1a5633 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=AD=20Adriano?= Date: Mon, 6 Jul 2026 11:21:34 -0300 Subject: [PATCH 16/21] =?UTF-8?q?fix:=20address=20bugbot=20round=202=20?= =?UTF-8?q?=E2=80=94=20marker=20advance,=20sync=20guard,=20profile=20inser?= =?UTF-8?q?ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shared/defaults/models.test.ts | 2 +- src/lib/reconcile-defaults.test.ts | 82 +++++++++++++++++++++- src/lib/reconcile-defaults.ts | 106 +++++++++++++++++++---------- 3 files changed, 152 insertions(+), 38 deletions(-) diff --git a/shared/defaults/models.test.ts b/shared/defaults/models.test.ts index a08d9b5e7..82a3f44c9 100644 --- a/shared/defaults/models.test.ts +++ b/shared/defaults/models.test.ts @@ -11,7 +11,7 @@ import { defaultModels, defaultModelsVersion, hashModel } from './models' * * 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. + * 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 diff --git a/src/lib/reconcile-defaults.test.ts b/src/lib/reconcile-defaults.test.ts index 0fbdb2e31..593c99b87 100644 --- a/src/lib/reconcile-defaults.test.ts +++ b/src/lib/reconcile-defaults.test.ts @@ -5,7 +5,7 @@ import { 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' @@ -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() }) @@ -758,6 +766,78 @@ describe('reconcileDefaults version gate (THU-637)', () => { 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() diff --git a/src/lib/reconcile-defaults.ts b/src/lib/reconcile-defaults.ts index c29f3d7d6..de51b7e5a 100644 --- a/src/lib/reconcile-defaults.ts +++ b/src/lib/reconcile-defaults.ts @@ -54,19 +54,35 @@ const readAppliedVersion = async ( * Options for `reconcileDefaultsForTable`. * * @property keyField - Name of the primary key field. Defaults to `'id'`. - * @property canOverwrite - When false, this pass is purely non-mutating for the - * table: no inserts of missing rows, no bootstrap of legacy null defaultHash, - * no updates. 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. Prevents ghost-inserting rows that a newer version deliberately - * removed but hasn't finished syncing to us (see THU-637 / AGENTS.md). + * @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). */ export type ReconcileDefaultsForTableOptions = { keyField?: string canOverwrite?: boolean + insertMissing?: boolean } +/** + * 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. @@ -80,36 +96,40 @@ export const reconcileDefaultsForTable = async string, options: ReconcileDefaultsForTableOptions = {}, -) => { - const { keyField = 'id', canOverwrite = true } = options +): Promise => { + const { keyField = 'id', canOverwrite = true, insertMissing = canOverwrite } = 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) { - // Row missing locally: only seed when authoritative. Otherwise a newer - // version's deliberate removal (still en route to us via sync) would be - // undone by us re-inserting the bundle's copy. - if (!canOverwrite) { + // 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 } - // Any write against an existing row also requires authority. + // Any write against an existing row requires authority. if (!canOverwrite) { continue } @@ -121,6 +141,7 @@ export const reconcileDefaultsForTable = async (stored.version ?? Number.NEGATIVE_INFINITY) - // Additional guard for the fresh-second-device / sync-timeout case: when - // the initial sync didn't complete AND we have no local stored version, - // cloud may hold both the version marker AND newer rows we haven't - // received. Acting on partial state would let us regress the marker or - // ghost-insert defaults the newer version has retired. Fresh installs - // (0 rows) still seed the bundle so the app isn't crippled offline. + // 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 rawCanOverwrite - } - if (!stored.exists) { + if (!initialSyncCompleted) { return false } return rawCanOverwrite @@ -259,20 +281,26 @@ export const reconcileDefaults = async (db: AnyDrizzleDatabase, overrides?: Reco await cleanupRemovedDefaults(tx, canOverwriteModels, modelsSource.data) // AI models - await reconcileDefaultsForTable(tx, modelsTable, modelsSource.data, hashModel, { + const modelsPass = await reconcileDefaultsForTable(tx, modelsTable, modelsSource.data, hashModel, { canOverwrite: canOverwriteModels, }) // Model profiles ship 1:1 with models and mutate together in practice, so - // they ride the same gate — 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. Insert-of-missing still runs regardless, so orphaned profiles are - // impossible even when overwrites are skipped. - await reconcileDefaultsForTable(tx, modelProfilesTable, defaultModelProfiles, hashModelProfile, { - keyField: 'modelId', - canOverwrite: canOverwriteModels, - }) + // 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 model + // present locally (via sync) always has a profile to pair with, even when + // canOverwrite is closed. The rare orphaned-profile edge (bundle contains + // a profile whose model the newer version retired) is silent — with no + // matching model row, DAL joins won't surface the orphan. + const profilesPass = await reconcileDefaultsForTable( + tx, + modelProfilesTable, + defaultModelProfiles, + hashModelProfile, + { keyField: 'modelId', canOverwrite: canOverwriteModels, insertMissing: true }, + ) // Modes await reconcileDefaultsForTable(tx, modesTable, defaultModes, hashMode) @@ -287,7 +315,13 @@ export const reconcileDefaults = async (db: AnyDrizzleDatabase, overrides?: Reco // Settings await reconcileDefaultsForTable(tx, settingsTable, defaultSettings, hashSetting, { keyField: 'key' }) - if (canOverwriteModels) { + // 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. + if (canOverwriteModels && (modelsPass.mutated || profilesPass.mutated)) { // 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 From 7675cdd3f6df9cd911a69f7d71d48c4058b9d77c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=AD=20Adriano?= Date: Mon, 6 Jul 2026 12:03:07 -0300 Subject: [PATCH 17/21] feat: land THU-645 model lineup update + resurrect race guard --- backend/src/inference/routes.test.ts | 2 +- backend/src/inference/routes.ts | 4 ++ backend/src/tinfoil/routes.test.ts | 2 +- shared/defaults/models.test.ts | 4 +- shared/defaults/models.ts | 54 ++++++++-------- .../ui/model-selector/model-selector.test.ts | 2 +- src/defaults/model-profiles.test.ts | 14 +++-- src/defaults/model-profiles.ts | 3 +- src/defaults/model-profiles/deepseek.ts | 6 +- src/defaults/model-profiles/index.ts | 9 +-- src/defaults/model-profiles/kimi.ts | 32 ---------- src/lib/reconcile-defaults.test.ts | 61 +++++++++++++++++-- src/lib/reconcile-defaults.ts | 24 ++++++++ 13 files changed, 129 insertions(+), 88 deletions(-) delete mode 100644 src/defaults/model-profiles/kimi.ts 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/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/shared/defaults/models.test.ts b/shared/defaults/models.test.ts index 82a3f44c9..484e13eaa 100644 --- a/shared/defaults/models.test.ts +++ b/shared/defaults/models.test.ts @@ -21,8 +21,8 @@ const computeSnapshotHash = () => defaultModels.map((model, index) => `${index}:${model.id}:${hashModel(model)}`).join('|') const expected = { - version: 1, - hash: '0:019af08a-c27b-7074-8aac-95315d1ef3fd:-1vf2pk|1:019e70af-e5b2-76d0-9ede-f22d8265bb14:i4q5q9|2:019e7580-2b0c-77d6-8b99-16a99abe4591:b3y2hj|3:019e7580-2b0e-719c-a43f-d2b56e7f31b4:-g7x2jr', + 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', () => { diff --git a/shared/defaults/models.ts b/shared/defaults/models.ts index 43a1feeec..f7a88b419 100644 --- a/shared/defaults/models.ts +++ b/shared/defaults/models.ts @@ -86,14 +86,23 @@ export const defaultModelOpus48: SharedModel = { userId: null, } -export const defaultModelDeepseekV4Pro: SharedModel = { - id: '019e70af-e5b2-76d0-9ede-f22d8265bb14', - name: 'DeepSeek V4 Pro', - provider: 'tinfoil', - model: 'deepseek-v4-pro', +/** + * 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. + */ +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: 1, + isConfidential: 0, contextWindow: 131072, toolUsage: 1, startWithReasoning: 0, @@ -102,27 +111,7 @@ export const defaultModelDeepseekV4Pro: SharedModel = { url: null, defaultHash: null, vendor: 'deepseek', - description: 'Confidential reasoning via Tinfoil', - userId: null, -} - -export const defaultModelKimiK26: SharedModel = { - 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, - url: null, - defaultHash: null, - vendor: 'moonshot', - description: 'Confidential chat via Tinfoil', + description: 'Fast DeepSeek reasoning', userId: null, } @@ -150,11 +139,16 @@ export const defaultModelGlm52: SharedModel = { * 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, - defaultModelDeepseekV4Pro, - defaultModelKimiK26, + defaultModelDeepseekV4Flash, defaultModelGlm52, ] as const @@ -168,4 +162,4 @@ export const defaultModels: ReadonlyArray = [ * 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 = 1 +export const defaultModelsVersion = 2 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/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 025ec68bf..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 '@shared/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/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 4c2a9b358..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 '@shared/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/lib/reconcile-defaults.test.ts b/src/lib/reconcile-defaults.test.ts index 593c99b87..88f597c74 100644 --- a/src/lib/reconcile-defaults.test.ts +++ b/src/lib/reconcile-defaults.test.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 { 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, beforeEach, describe, expect, test } from 'bun:test' @@ -12,7 +12,6 @@ import { defaultAutomations, hashPrompt } from '../defaults/automations' import { 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, reconcileDefaults, reconcileDefaultsForTable } from './reconcile-defaults' import type { Model, ModelProfile, Prompt } from '@/types' @@ -159,8 +158,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) @@ -188,8 +190,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()) @@ -528,6 +531,52 @@ describe('reconcileDefaultsForTable', () => { expect(settings.length).toBe(0) }) + test('canOverwrite=false 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). The resurrect branch must fire even under the + // strictest gate, 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 }) + + const row = await db.select().from(modelsTable).where(eq(modelsTable.id, shipped.id)).get() + expect(row?.deletedAt).toBeNull() + }) + + 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. + 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 }) + + 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() diff --git a/src/lib/reconcile-defaults.ts b/src/lib/reconcile-defaults.ts index de51b7e5a..923bb4837 100644 --- a/src/lib/reconcile-defaults.ts +++ b/src/lib/reconcile-defaults.ts @@ -129,6 +129,30 @@ export const reconcileDefaultsForTable = async Date: Mon, 6 Jul 2026 12:47:50 -0300 Subject: [PATCH 18/21] fix: gate resurrect and profile-cleanup on sync completion --- src/lib/reconcile-defaults.test.ts | 118 +++++++++++++++++++++++++++-- src/lib/reconcile-defaults.ts | 66 +++++++++++----- 2 files changed, 158 insertions(+), 26 deletions(-) diff --git a/src/lib/reconcile-defaults.test.ts b/src/lib/reconcile-defaults.test.ts index 88f597c74..00279929c 100644 --- a/src/lib/reconcile-defaults.test.ts +++ b/src/lib/reconcile-defaults.test.ts @@ -9,7 +9,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } fr 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 { defaultModelProfiles, hashModelProfile } from '../defaults/model-profiles' import { defaultModels, defaultModelsVersion, hashModel, type SharedModel } from '@shared/defaults/models' import { defaultSettings, hashSetting } from '../defaults/settings' import { cleanupRemovedDefaults, reconcileDefaults, reconcileDefaultsForTable } from './reconcile-defaults' @@ -531,15 +531,15 @@ describe('reconcileDefaultsForTable', () => { expect(settings.length).toBe(0) }) - test('canOverwrite=false still resurrects a cleanup-shaped soft-delete', async () => { + 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). The resurrect branch must fire even under the - // strictest gate, otherwise the row stays deleted for good once - // `stored.version` catches up. + // 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, @@ -547,19 +547,45 @@ describe('reconcileDefaultsForTable', () => { defaultHash: hashModel(shipped), }) - await reconcileDefaultsForTable(db, modelsTable, defaultModels, hashModel, { canOverwrite: false }) + 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. + // the user's deletion is preserved regardless of canResurrect. const shipped = defaultModels[0] await db.insert(modelsTable).values({ ...shipped, @@ -571,7 +597,10 @@ describe('reconcileDefaultsForTable', () => { defaultHash: null, }) - await reconcileDefaultsForTable(db, modelsTable, defaultModels, hashModel, { canOverwrite: false }) + 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') @@ -924,4 +953,77 @@ describe('reconcileDefaults version gate (THU-637)', () => { .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() + }) }) diff --git a/src/lib/reconcile-defaults.ts b/src/lib/reconcile-defaults.ts index 923bb4837..e67e3f0f1 100644 --- a/src/lib/reconcile-defaults.ts +++ b/src/lib/reconcile-defaults.ts @@ -65,11 +65,21 @@ const readAppliedVersion = async ( * 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. */ export type ReconcileDefaultsForTableOptions = { keyField?: string canOverwrite?: boolean insertMissing?: boolean + canResurrect?: boolean } /** @@ -97,7 +107,7 @@ export const reconcileDefaultsForTable = async string, options: ReconcileDefaultsForTableOptions = {}, ): Promise => { - const { keyField = 'id', canOverwrite = true, insertMissing = canOverwrite } = options + const { keyField = 'id', canOverwrite = true, insertMissing = canOverwrite, canResurrect = canOverwrite } = options if (defaults.length === 0) { return { mutated: false } @@ -139,17 +149,21 @@ export const reconcileDefaultsForTable = async { const now = nowIso() const currentModelIds = new Set(models.map((m) => m.id)) @@ -244,9 +263,12 @@ export const cleanupRemovedDefaults = async ( // 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) @@ -302,11 +324,14 @@ export const reconcileDefaults = async (db: AnyDrizzleDatabase, overrides?: Reco })() // Soft-delete removed system defaults before reconciling current ones. - await cleanupRemovedDefaults(tx, canOverwriteModels, modelsSource.data) + await cleanupRemovedDefaults(tx, canOverwriteModels, modelsSource.data, initialSyncCompleted) - // AI models + // 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. const modelsPass = await reconcileDefaultsForTable(tx, modelsTable, modelsSource.data, hashModel, { canOverwrite: canOverwriteModels, + canResurrect: initialSyncCompleted, }) // Model profiles ship 1:1 with models and mutate together in practice, so @@ -323,7 +348,12 @@ export const reconcileDefaults = async (db: AnyDrizzleDatabase, overrides?: Reco modelProfilesTable, defaultModelProfiles, hashModelProfile, - { keyField: 'modelId', canOverwrite: canOverwriteModels, insertMissing: true }, + { + keyField: 'modelId', + canOverwrite: canOverwriteModels, + insertMissing: true, + canResurrect: initialSyncCompleted, + }, ) // Modes From 4bcb4022135067f57a1f4134aaba662b35204c9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=AD=20Adriano?= Date: Mon, 6 Jul 2026 12:57:33 -0300 Subject: [PATCH 19/21] fix: filter OTA models without a bundled profile from reconcile --- src/lib/reconcile-defaults.test.ts | 55 ++++++++++++++++++++++++++++++ src/lib/reconcile-defaults.ts | 39 +++++++++++++++++---- 2 files changed, 87 insertions(+), 7 deletions(-) diff --git a/src/lib/reconcile-defaults.test.ts b/src/lib/reconcile-defaults.test.ts index 00279929c..483b43bec 100644 --- a/src/lib/reconcile-defaults.test.ts +++ b/src/lib/reconcile-defaults.test.ts @@ -12,6 +12,7 @@ import { defaultAutomations, hashPrompt } from '../defaults/automations' import { defaultModelProfiles, hashModelProfile } from '../defaults/model-profiles' import { defaultModels, defaultModelsVersion, hashModel, type SharedModel } from '@shared/defaults/models' import { defaultSettings, hashSetting } from '../defaults/settings' +import type { ModelsDefaults } from './pick-defaults' import { cleanupRemovedDefaults, reconcileDefaults, reconcileDefaultsForTable } from './reconcile-defaults' import type { Model, ModelProfile, Prompt } from '@/types' @@ -1026,4 +1027,58 @@ describe('reconcileDefaults version gate (THU-637)', () => { .get() expect(profile?.deletedAt).toBeNull() }) + + 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 e67e3f0f1..db52e9759 100644 --- a/src/lib/reconcile-defaults.ts +++ b/src/lib/reconcile-defaults.ts @@ -323,13 +323,33 @@ export const reconcileDefaults = async (db: AnyDrizzleDatabase, overrides?: Reco 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. + // 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. await cleanupRemovedDefaults(tx, canOverwriteModels, modelsSource.data, initialSyncCompleted) // 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. - const modelsPass = await reconcileDefaultsForTable(tx, modelsTable, modelsSource.data, hashModel, { + const modelsPass = await reconcileDefaultsForTable(tx, modelsTable, modelsForReconcile, hashModel, { canOverwrite: canOverwriteModels, canResurrect: initialSyncCompleted, }) @@ -338,11 +358,11 @@ export const reconcileDefaults = async (db: AnyDrizzleDatabase, overrides?: Reco // 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 model - // present locally (via sync) always has a profile to pair with, even when - // canOverwrite is closed. The rare orphaned-profile edge (bundle contains - // a profile whose model the newer version retired) is silent — with no - // matching model row, DAL joins won't surface the orphan. + // 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 bundle-known model ids: OTA-only-new + // ids were dropped from the models pass above (they have no bundled + // profile to insert here either). const profilesPass = await reconcileDefaultsForTable( tx, modelProfilesTable, @@ -375,7 +395,12 @@ export const reconcileDefaults = async (db: AnyDrizzleDatabase, overrides?: Reco // 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. - if (canOverwriteModels && (modelsPass.mutated || profilesPass.mutated)) { + // + // 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 From 107513d0ee56157f9959fbfc5d438fa64afd2ae8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=AD=20Adriano?= Date: Mon, 6 Jul 2026 13:16:57 -0300 Subject: [PATCH 20/21] fix: reject disjoint OTA payloads in pickModelsDefaults --- src/lib/pick-defaults.test.ts | 34 ++++++++++++++++++++++++++++++++++ src/lib/pick-defaults.ts | 28 ++++++++++++++++++++++------ src/lib/reconcile-defaults.ts | 5 +++++ 3 files changed, 61 insertions(+), 6 deletions(-) diff --git a/src/lib/pick-defaults.test.ts b/src/lib/pick-defaults.test.ts index d5385aa9c..5afa25370 100644 --- a/src/lib/pick-defaults.test.ts +++ b/src/lib/pick-defaults.test.ts @@ -64,4 +64,38 @@ describe('pickModelsDefaults', () => { 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 index fcc0b3b99..1a4457716 100644 --- a/src/lib/pick-defaults.ts +++ b/src/lib/pick-defaults.ts @@ -21,11 +21,16 @@ type ServerModelsDefaults = { version: number; data: SharedModel[] } * 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. * - * A server payload with a bumped version but missing / non-array / empty `data` - * is treated as malformed and rejected — otherwise `cleanupRemovedDefaults` - * would soft-delete every unedited system model on the way past (empty - * `currentModelIds` matches nothing) and the stored version would advance, - * making local recovery impossible short of another OTA. + * 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 ( @@ -35,7 +40,18 @@ export const pickModelsDefaults = (server: ServerModelsDefaults | undefined): Mo Array.isArray(server.data) && server.data.length > 0 ) { - return { version: server.version, data: server.data } + 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.ts b/src/lib/reconcile-defaults.ts index db52e9759..1264ac3e9 100644 --- a/src/lib/reconcile-defaults.ts +++ b/src/lib/reconcile-defaults.ts @@ -344,6 +344,11 @@ export const reconcileDefaults = async (db: AnyDrizzleDatabase, overrides?: Reco // Soft-delete removed system defaults before reconciling current ones. // 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. `canResurrect` uses initialSyncCompleted so an older-bundle From d97d297d17b4c8e1ab6c0680b687b5430ee34bce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=AD=20Adriano?= Date: Mon, 6 Jul 2026 18:18:11 -0300 Subject: [PATCH 21/21] fix(THU-637): address open PR review blockers --- backend/src/db/client.ts | 17 ++++- shared/defaults/models.ts | 5 ++ src/hooks/use-app-initialization.ts | 22 +++--- src/lib/reconcile-defaults.test.ts | 100 ++++++++++++++++++++++++++++ src/lib/reconcile-defaults.ts | 70 +++++++++++++++---- 5 files changed, 188 insertions(+), 26 deletions(-) diff --git a/backend/src/db/client.ts b/backend/src/db/client.ts index 08996f91f..d0cc82b14 100644 --- a/backend/src/db/client.ts +++ b/backend/src/db/client.ts @@ -43,10 +43,25 @@ const pgliteDataDir = ? 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 '' + } +} + 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 ` + - `(${process.env.DATABASE_URL}) — falling back to in-memory PGlite. ` + + `(${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.`, ) } diff --git a/shared/defaults/models.ts b/shared/defaults/models.ts index f7a88b419..d8fb580cc 100644 --- a/shared/defaults/models.ts +++ b/shared/defaults/models.ts @@ -94,6 +94,11 @@ export const defaultModelOpus48: SharedModel = { * `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', diff --git a/src/hooks/use-app-initialization.ts b/src/hooks/use-app-initialization.ts index 47b4409ce..6b82670cb 100644 --- a/src/hooks/use-app-initialization.ts +++ b/src/hooks/use-app-initialization.ts @@ -162,16 +162,15 @@ 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. Skip the - // await when the persisted store already has a defaults payload from a prior - // successful fetch — the value is used immediately and the in-flight - // fetchConfigPromise resolves in the background for future launches. This - // avoids serializing reconcile behind up to 5 s of network wait on the happy - // (cache-hit) path. First-ever launch (no persisted payload) still awaits. - const persistedModelsDefaults = useConfigStore.getState().config.defaults?.models - if (!persistedModelsDefaults) { - await fetchConfigPromise - } + // 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' @@ -232,9 +231,6 @@ const executeInitializationSteps = async (httpClient?: HttpClient): Promise { 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() diff --git a/src/lib/reconcile-defaults.ts b/src/lib/reconcile-defaults.ts index 1264ac3e9..564554720 100644 --- a/src/lib/reconcile-defaults.ts +++ b/src/lib/reconcile-defaults.ts @@ -74,12 +74,22 @@ const readAppliedVersion = async ( * 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[] } /** @@ -107,7 +117,13 @@ export const reconcileDefaultsForTable = async string, options: ReconcileDefaultsForTableOptions = {}, ): Promise => { - const { keyField = 'id', canOverwrite = true, insertMissing = canOverwrite, canResurrect = canOverwrite } = options + const { + keyField = 'id', + canOverwrite = true, + insertMissing = canOverwrite, + canResurrect = canOverwrite, + frozenFields = [], + } = options if (defaults.length === 0) { return { mutated: false } @@ -174,10 +190,10 @@ 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 } @@ -198,7 +225,7 @@ export const reconcileDefaultsForTable = async m.id)) + const profilesForReconcile = defaultModelProfiles.filter((p) => aliveModelIdsForReconcile.has(p.modelId)) const profilesPass = await reconcileDefaultsForTable( tx, modelProfilesTable, - defaultModelProfiles, + profilesForReconcile, hashModelProfile, { keyField: 'modelId',