Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions api/_lib/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,18 @@ async function rawSet(key, value, ttlSec) {
writeDevStore(store);
}

async function rawDel(key) {
if (useKv) {
await kv().del(key);
return;
}
const store = readDevStore();
if (key in store) {
delete store[key];
writeDevStore(store);
}
}

async function writeKvPointers(entitlement) {
if (!entitlement.subscriptionId && !entitlement.email) return;
if (entitlement.subscriptionId) {
Expand Down Expand Up @@ -296,5 +308,6 @@ module.exports = {
usingNeon: useNeon,
rawGet,
rawSet,
rawDel,
licenseDb,
};
7 changes: 7 additions & 0 deletions api/_lib/sync-db.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,13 @@ async function ensureSchema() {
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`;
// Migrate sync_accounts created by older deploys — CREATE TABLE IF NOT EXISTS
// never alters an existing table, so newer columns must be added explicitly.
await db`ALTER TABLE pgstudio_sync.sync_accounts ADD COLUMN IF NOT EXISTS tier TEXT NOT NULL DEFAULT 'sponsor'`;
await db`ALTER TABLE pgstudio_sync.sync_accounts ADD COLUMN IF NOT EXISTS bytes_used BIGINT NOT NULL DEFAULT 0`;
await db`ALTER TABLE pgstudio_sync.sync_accounts ADD COLUMN IF NOT EXISTS item_count INT NOT NULL DEFAULT 0`;
await db`ALTER TABLE pgstudio_sync.sync_accounts ADD COLUMN IF NOT EXISTS inactive_since TIMESTAMPTZ`;
await db`ALTER TABLE pgstudio_sync.sync_accounts ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT now()`;
await db`
CREATE TABLE IF NOT EXISTS pgstudio_sync.sync_devices (
account_id TEXT NOT NULL,
Expand Down
13 changes: 13 additions & 0 deletions docs/CLOUD_SYNC.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,19 @@ Pick connections, saved queries, notebooks, and optionally passwords. A first sy
| Pause / resume | **`NexQL Sync: Pause Sync`** |
| Sign out | **`NexQL Sync: Sign Out of Sync`** — local data kept; clears local vault session |

### Deleting synced items

Deleting a connection, saved query, or notebook on one device removes it everywhere — the same delete (tombstone) protocol is used by **every** backend, including NexQL Cloud and self-hosted Shared Postgres:

1. The item is removed locally and a **delete** is queued in the pending changes list.
2. The next sync pushes a **tombstone** to the backend using the item's last-synced version as a compare-and-swap base, so a concurrent edit from another device can't be silently clobbered.
3. Once the backend acknowledges the tombstone, the item is dropped from the local sync index and the pending entry clears.
4. Other devices receive the tombstone on their next pull and remove their local copy. Tombstones are permanent — a deleted item is never resurrected by a later sync.

**Open editors are handled safely.** Deleting a notebook whose tab is still open no longer re-uploads or "resurrects" it: a document whose backing file is gone is skipped during collection, so the delete propagates cleanly even with the tab open. Items that were never pushed to the backend are simply dropped from the queue (there is nothing remote to delete).

> If you deleted notebooks in an older build and the cloud copy lingered, re-deleting them now pushes a proper tombstone. Alternatively, **Replace Cloud with Local** (from the Sync menu) force-pushes this device's state as the source of truth.

### Settings

Configure in **Settings → PostgreSQL Explorer → Sync**:
Expand Down
10 changes: 6 additions & 4 deletions src/activation/commandSpecs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -672,11 +672,13 @@ export function getCommandSpecs(
await vscode.workspace.fs.delete(item.uri, { recursive: false });
if (syncId) {
try {
const { SyncIndex } = await import('../features/sync/SyncIndex');
const { recordSyncActivity } = await import('../features/sync/SyncActivityLog');
const index = new SyncIndex(context);
index.remove(syncId);
await index.flush();
// Do NOT remove the index entry here. The sync engine emits a cloud
// tombstone by walking syncedIds() for items that were synced before
// and are now gone locally (buildOps delete branch). Removing it up
// front strips the compare-and-swap base, so the delete would never
// propagate to the cloud or other devices. recordAccepted() prunes
// the index once the tombstone is acknowledged.
recordSyncActivity({
kind: 'notebook',
action: 'delete',
Expand Down
17 changes: 16 additions & 1 deletion src/features/aiAssistant/AiModelCatalogService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,11 @@ import {
listAnthropicModels,
listCursorModels,
listCustomModels,
listDeepSeekModels,
listGeminiModels,
listGitHubModels,
listMistralModels,
listMoonshotModels,
listOpenAIModels,
listVsCodeLanguageModels,
} from './modelListing';
Expand Down Expand Up @@ -108,7 +111,7 @@ export class AiModelCatalogService {

await this._appendProviderModels(catalog, 'opencode', async () => listOpencodeModels(config));

for (const provider of ['openai', 'anthropic', 'gemini'] as DirectApiKeyProvider[]) {
for (const provider of ['openai', 'anthropic', 'gemini', 'deepseek', 'moonshot', 'mistral'] as DirectApiKeyProvider[]) {
const apiKey = await this.credentials.getApiKey(provider);
if (apiKey) {
await this._appendProviderModels(catalog, provider, () => this._listForDirectProvider(provider, apiKey));
Expand Down Expand Up @@ -214,6 +217,12 @@ export class AiModelCatalogService {
return listAnthropicModels(apiKey);
case 'gemini':
return listGeminiModels(apiKey);
case 'deepseek':
return listDeepSeekModels(apiKey);
case 'moonshot':
return listMoonshotModels(apiKey);
case 'mistral':
return listMistralModels(apiKey);
case 'custom':
return [];
default:
Expand All @@ -229,6 +238,12 @@ export class AiModelCatalogService {
return 'claude-sonnet-4-20250514';
case 'gemini':
return 'gemini-2.5-flash';
case 'deepseek':
return 'deepseek-chat';
case 'moonshot':
return 'moonshot-v1-8k';
case 'mistral':
return 'mistral-large-latest';
case 'github':
return 'openai/gpt-4.1';
case 'cursor':
Expand Down
6 changes: 6 additions & 0 deletions src/features/aiAssistant/aiConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,12 @@ export function providerDisplayName(provider: AiProviderId): string {
return 'Anthropic';
case 'gemini':
return 'Gemini';
case 'deepseek':
return 'DeepSeek';
case 'moonshot':
return 'Moonshot / Kimi';
case 'mistral':
return 'Mistral AI';
case 'custom':
return 'Custom';
case 'ollama':
Expand Down
68 changes: 68 additions & 0 deletions src/features/aiAssistant/modelListing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,74 @@ export async function listCustomModels(endpoint: string, apiKey: string): Promis
});
}

/** Shared helper: fetch GET /v1/models from any OpenAI-compatible host with Bearer auth. */
function listOpenAiCompatibleModels(
hostname: string,
apiKey: string,
filter?: (id: string) => boolean,
): Promise<string[]> {
return new Promise((resolve, reject) => {
const options = {
hostname,
path: '/v1/models',
method: 'GET',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
};

const req = https.request(options, (res) => {
let body = '';
res.on('data', (chunk) => (body += chunk));
res.on('end', () => {
if (res.statusCode === 200) {
try {
const data = JSON.parse(body);
let models: string[] = (data.data ?? [])
.map((m: { id: string }) => m.id)
.filter(Boolean);
if (filter) {
models = models.filter(filter);
}
resolve(models.sort());
} catch {
reject(new Error('Failed to parse models response'));
}
} else {
reject(new Error(`Failed to list models: ${res.statusCode} - ${body}`));
}
});
});

req.on('error', (err) => reject(err));
req.end();
});
}

/** List DeepSeek chat models (platform.deepseek.com — OpenAI-compatible). */
export function listDeepSeekModels(apiKey: string): Promise<string[]> {
return listOpenAiCompatibleModels('api.deepseek.com', apiKey, (id) =>
id.startsWith('deepseek-'),
);
}

/** List Moonshot / Kimi models (platform.moonshot.cn — OpenAI-compatible). */
export function listMoonshotModels(apiKey: string): Promise<string[]> {
return listOpenAiCompatibleModels('api.moonshot.cn', apiKey, (id) =>
id.startsWith('moonshot-'),
);
}

/** List Mistral AI models (api.mistral.ai — OpenAI-compatible). */
export function listMistralModels(apiKey: string): Promise<string[]> {
// Mistral returns chat-capable models; exclude embedding-only ones
return listOpenAiCompatibleModels('api.mistral.ai', apiKey, (id) =>
!id.includes('embed'),
);
}


export interface VsCodeLanguageModelEntry {
id: string;
displayName: string;
Expand Down
12 changes: 11 additions & 1 deletion src/features/aiAssistant/types.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
/** Providers that use a per-provider API key in secret storage. */
export type DirectApiKeyProvider = 'openai' | 'anthropic' | 'gemini' | 'custom';
export type DirectApiKeyProvider =
| 'openai'
| 'anthropic'
| 'gemini'
| 'deepseek'
| 'moonshot'
| 'mistral'
| 'custom';

export const DIRECT_API_KEY_PROVIDERS: readonly DirectApiKeyProvider[] = [
'openai',
'anthropic',
'gemini',
'deepseek',
'moonshot',
'mistral',
'custom',
] as const;

Expand Down
Loading
Loading