From 6d92dafbe099687f409af068359f5043c1886689 Mon Sep 17 00:00:00 2001 From: Richie Varghese Date: Sun, 14 Jun 2026 21:31:18 +0530 Subject: [PATCH 1/3] feat: enhance license management and cron functionality - Added a new cron job for syncing and purging inactive licenses, improving resource management. - Updated webhook handling to mark accounts as active or inactive based on license status, enhancing user account management. - Refactored license expiration logic to return expired license keys for better tracking. - Introduced new functions for marking accounts inactive and purging cloud data, streamlining license management processes. - Updated documentation to reflect changes in license retention policies and cron job functionalities. --- api/_lib/license-db.js | 2 +- api/_lib/sync-auth.js | 19 +- api/_lib/sync-db.js | 92 ++++- api/_lib/sync-retention.js | 4 + api/cron/license-expiry.js | 16 +- api/cron/sync-purge-inactive.js | 31 ++ api/webhook.js | 17 + docs/CLOUD_SYNC.md | 4 +- resources/platform-icons/alloydb.svg | 42 ++- resources/platform-icons/aurora.svg | 1 + resources/platform-icons/aws.svg | 2 +- resources/platform-icons/azure.svg | 2 +- resources/platform-icons/googlecloud.svg | 42 ++- resources/platform-icons/neon.svg | 5 +- resources/platform-icons/postgresql.svg | 2 +- resources/platform-icons/supabase.svg | 2 +- resources/platform-icons/timescale.svg | 2 +- src/features/settings/SettingsHubPanel.ts | 2 +- src/features/settings/handlers/sync.ts | 2 +- src/features/sync/SyncController.ts | 103 +++++- src/features/sync/constants.ts | 2 + src/features/sync/types.ts | 2 + src/lib/platform/connectionPresets.ts | 2 +- templates/settings-hub/index.html | 196 ++++++---- templates/settings-hub/scripts.js | 306 +++++++++++++--- templates/settings-hub/styles.css | 427 ++++++++++++++++++++++ vercel.json | 8 + 27 files changed, 1171 insertions(+), 164 deletions(-) create mode 100644 api/_lib/sync-retention.js create mode 100644 api/cron/sync-purge-inactive.js create mode 100644 resources/platform-icons/aurora.svg diff --git a/api/_lib/license-db.js b/api/_lib/license-db.js index 0a8767c..d8133d7 100644 --- a/api/_lib/license-db.js +++ b/api/_lib/license-db.js @@ -475,7 +475,7 @@ async function expirePastDueLicenses() { expired_at: isoToMs(row.expires_at), }, 'cron'); } - return rows.length; + return rows.map((row) => row.license_key); } module.exports = { diff --git a/api/_lib/sync-auth.js b/api/_lib/sync-auth.js index f9b6969..dbc2814 100644 --- a/api/_lib/sync-auth.js +++ b/api/_lib/sync-auth.js @@ -192,8 +192,12 @@ async function createSessionFromLicense(licenseKey, instanceId, deviceId, device const access = await storeToken(accountId, 'access', ACCESS_TTL_SEC); const refresh = await storeToken(accountId, 'refresh', REFRESH_TTL_SEC); - const { upsertDevice, setAccountTier } = require('./sync-db'); + const { upsertDevice, setAccountTier, markAccountActive, purgeInactiveCloudData } = require('./sync-db'); await setAccountTier(accountId, license.entitlement.tier || 'sponsor'); + await markAccountActive(accountId); + void purgeInactiveCloudData().catch((err) => { + console.error('sync-auth: purgeInactiveCloudData failed', err); + }); if (deviceId) { await upsertDevice(accountId, String(deviceId), deviceName ? String(deviceName) : undefined); } @@ -285,9 +289,14 @@ async function refreshAccessToken(refreshToken) { const license = await validateLicenseKey(record.account_id); if (!license.ok) { + const { markAccountInactive } = require('./sync-db'); + await markAccountInactive(record.account_id); return { error: 'invalid_grant', error_description: license.error }; } + const { markAccountActive } = require('./sync-db'); + await markAccountActive(record.account_id); + await kvDel(TOKEN_PREFIX + sha256(refreshToken)); const access = await storeToken(record.account_id, 'access', ACCESS_TTL_SEC); const refresh = await storeToken(record.account_id, 'refresh', REFRESH_TTL_SEC); @@ -313,11 +322,17 @@ async function authenticateBearer(req) { const license = await validateLicenseKey(record.account_id); if (!license.ok) { + const { markAccountInactive } = require('./sync-db'); + await markAccountInactive(record.account_id); return null; } - const { upsertDevice, setAccountTier } = require('./sync-db'); + const { upsertDevice, setAccountTier, markAccountActive, purgeInactiveCloudData } = require('./sync-db'); await setAccountTier(record.account_id, license.entitlement.tier || 'sponsor'); + await markAccountActive(record.account_id); + void purgeInactiveCloudData().catch((err) => { + console.error('sync-auth: purgeInactiveCloudData failed', err); + }); const deviceId = req.headers['x-device-id'] || req.headers['X-Device-Id']; const deviceName = req.headers['x-device-name'] || req.headers['X-Device-Name']; diff --git a/api/_lib/sync-db.js b/api/_lib/sync-db.js index 265204e..4db525d 100644 --- a/api/_lib/sync-db.js +++ b/api/_lib/sync-db.js @@ -77,13 +77,18 @@ async function ensureSchema() { `; await db` CREATE TABLE IF NOT EXISTS pgstudio_sync.sync_accounts ( - account_id TEXT PRIMARY KEY, - tier TEXT NOT NULL DEFAULT 'sponsor', - bytes_used BIGINT NOT NULL DEFAULT 0, - item_count INT NOT NULL DEFAULT 0, - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + account_id TEXT PRIMARY KEY, + tier TEXT NOT NULL DEFAULT 'sponsor', + bytes_used BIGINT NOT NULL DEFAULT 0, + item_count INT NOT NULL DEFAULT 0, + inactive_since TIMESTAMPTZ, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ) `; + await db` + ALTER TABLE pgstudio_sync.sync_accounts + ADD COLUMN IF NOT EXISTS inactive_since TIMESTAMPTZ + `; await db` CREATE TABLE IF NOT EXISTS pgstudio_sync.sync_devices ( account_id TEXT NOT NULL, @@ -360,6 +365,80 @@ async function revokeDevice(accountId, deviceId) { return rows.length > 0; } +const { CLOUD_INACTIVE_RETENTION_DAYS } = require('./sync-retention'); + +/** Mark cloud storage inactive (license lapsed). Retains prior inactive_since if already set. */ +async function markAccountInactive(accountId) { + if (!accountId) { + return; + } + await ensureSchema(); + const db = getSql(); + await db` + INSERT INTO pgstudio_sync.sync_accounts (account_id, inactive_since, updated_at) + VALUES (${accountId}, now(), now()) + ON CONFLICT (account_id) DO UPDATE SET + inactive_since = COALESCE(pgstudio_sync.sync_accounts.inactive_since, now()), + updated_at = now() + `; +} + +/** Clear inactive flag when paid sync access is restored. */ +async function markAccountActive(accountId) { + if (!accountId) { + return; + } + await ensureSchema(); + const db = getSql(); + await db` + UPDATE pgstudio_sync.sync_accounts + SET inactive_since = NULL, updated_at = now() + WHERE account_id = ${accountId} + `; +} + +async function deleteAccountCloudData(accountId, ownerEmail) { + await ensureSchema(); + const db = getSql(); + await db`DELETE FROM pgstudio_sync.sync_items WHERE account_id = ${accountId}`; + await db`DELETE FROM pgstudio_sync.sync_devices WHERE account_id = ${accountId}`; + if (ownerEmail) { + const email = String(ownerEmail).trim().toLowerCase(); + await db`DELETE FROM pgstudio_sync.sync_shares WHERE owner_email = ${email}`; + } + await db`DELETE FROM pgstudio_sync.sync_accounts WHERE account_id = ${accountId}`; +} + +/** Remove cloud blobs for accounts inactive longer than {@link CLOUD_INACTIVE_RETENTION_DAYS}. */ +async function purgeInactiveCloudData() { + await ensureSchema(); + const db = getSql(); + const rows = await db` + SELECT account_id + FROM pgstudio_sync.sync_accounts + WHERE inactive_since IS NOT NULL + AND inactive_since < now() - (${CLOUD_INACTIVE_RETENTION_DAYS} * interval '1 day') + `; + if (!rows.length) { + return 0; + } + + const store = require('./store'); + let purged = 0; + for (const row of rows) { + let ownerEmail = null; + try { + const ent = await store.getEntitlement(row.account_id); + ownerEmail = ent?.email ?? null; + } catch (err) { + console.error('purgeInactiveCloudData: entitlement lookup failed', row.account_id, err); + } + await deleteAccountCloudData(row.account_id, ownerEmail); + purged += 1; + } + return purged; +} + module.exports = { ensureSchema, listManifest, @@ -378,4 +457,7 @@ module.exports = { upsertDevice, listDevices, revokeDevice, + markAccountInactive, + markAccountActive, + purgeInactiveCloudData, }; diff --git a/api/_lib/sync-retention.js b/api/_lib/sync-retention.js new file mode 100644 index 0000000..d055155 --- /dev/null +++ b/api/_lib/sync-retention.js @@ -0,0 +1,4 @@ +/** Days NexQL Cloud keeps encrypted blobs after the account loses paid sync access. */ +const CLOUD_INACTIVE_RETENTION_DAYS = 30; + +module.exports = { CLOUD_INACTIVE_RETENTION_DAYS }; diff --git a/api/cron/license-expiry.js b/api/cron/license-expiry.js index 6b4cb5d..82a612d 100644 --- a/api/cron/license-expiry.js +++ b/api/cron/license-expiry.js @@ -21,8 +21,20 @@ module.exports = async (req, res) => { } try { - const expired = await licenseDb.expirePastDueLicenses(); - return res.status(200).json({ ok: true, expired }); + const expiredKeys = await licenseDb.expirePastDueLicenses(); + let markedInactive = 0; + if (expiredKeys.length > 0) { + try { + const { markAccountInactive } = require('../_lib/sync-db'); + for (const licenseKey of expiredKeys) { + await markAccountInactive(licenseKey); + markedInactive += 1; + } + } catch (err) { + console.error('license-expiry: markAccountInactive failed', err); + } + } + return res.status(200).json({ ok: true, expired: expiredKeys.length, markedInactive }); } catch (err) { console.error('license-expiry cron failed', err); return res.status(500).json({ error: 'Cron failed' }); diff --git a/api/cron/sync-purge-inactive.js b/api/cron/sync-purge-inactive.js new file mode 100644 index 0000000..84d3bb1 --- /dev/null +++ b/api/cron/sync-purge-inactive.js @@ -0,0 +1,31 @@ +// Daily cron: purge NexQL Cloud blobs for accounts inactive > 30 days. +// Secured with CRON_SECRET (Authorization: Bearer ). + +const { purgeInactiveCloudData } = require('../_lib/sync-db'); +const { CLOUD_INACTIVE_RETENTION_DAYS } = require('../_lib/sync-retention'); + +module.exports = async (req, res) => { + if (req.method !== 'GET' && req.method !== 'POST') { + return res.status(405).json({ error: 'Method Not Allowed' }); + } + + const secret = process.env.CRON_SECRET; + if (secret) { + const auth = req.headers.authorization || ''; + if (auth !== `Bearer ${secret}`) { + return res.status(401).json({ error: 'Unauthorized' }); + } + } + + try { + const purged = await purgeInactiveCloudData(); + return res.status(200).json({ + ok: true, + purged, + retentionDays: CLOUD_INACTIVE_RETENTION_DAYS, + }); + } catch (err) { + console.error('sync-purge-inactive cron failed', err); + return res.status(500).json({ error: 'Cron failed' }); + } +}; diff --git a/api/webhook.js b/api/webhook.js index 90a1c8e..dd72520 100644 --- a/api/webhook.js +++ b/api/webhook.js @@ -150,6 +150,15 @@ module.exports = async (req, res) => { razorpayEvent: razorpayEventId, }); + if (licenseKey) { + try { + const { markAccountActive } = require('./_lib/sync-db'); + await markAccountActive(licenseKey); + } catch (err) { + console.error('webhook: markAccountActive failed', err); + } + } + if (isNew && email) { await sendLicenseEmail(email, licenseKey, tier); } @@ -164,6 +173,14 @@ module.exports = async (req, res) => { source: 'webhook', razorpayEvent: razorpayEventId, }); + if (existing.licenseKey && ['cancelled', 'halted', 'paused'].includes(existing.status)) { + try { + const { markAccountInactive } = require('./_lib/sync-db'); + await markAccountInactive(existing.licenseKey); + } catch (err) { + console.error('webhook: markAccountInactive failed', err); + } + } } return res.status(200).json({ ok: true, event, status: STATUS_EVENTS[event] }); } diff --git a/docs/CLOUD_SYNC.md b/docs/CLOUD_SYNC.md index 97d3eae..2fac4bb 100644 --- a/docs/CLOUD_SYNC.md +++ b/docs/CLOUD_SYNC.md @@ -140,7 +140,7 @@ Sharing requires the NexQL Cloud backend (the only backend with a broker for pub Activate a license with **`NexQL: Activate License`** (`postgres-explorer.license.activate`). Manage status with **`NexQL: Manage License`**. -If your plan does not include the configured backend (e.g. after a downgrade), sync stops with an upgrade prompt — remote data stays intact. Choose **View Plans** to open [pricing](https://nexql.astrx.dev/#pricing). +If your plan does not include the configured backend (e.g. after a downgrade), sync stops with an upgrade prompt. NexQL Cloud data is kept for **30 days** while inactive, then deleted. Choose **View Plans** to open [pricing](https://nexql.astrx.dev/#pricing). --- @@ -152,7 +152,7 @@ If your plan does not include the configured backend (e.g. after a downgrade), s | **Email change** | The vault KEK is derived from your email + secret key. Changing email requires unlocking with the original email or re-creating the vault. | | **GitHub Gist size** | Each gist file is limited to **1 MB**. Very large notebooks may be skipped with a warning. | | **Google Drive** | Production use may require [Google OAuth app verification](https://developers.google.com/identity/protocols/oauth2/production-readiness). | -| **Downgraded license** | If your subscription lapses, backends above your tier stop syncing; existing remote data remains but cannot be updated until you renew. | +| **Downgraded license** | If your subscription lapses, backends above your tier stop syncing. NexQL Cloud data is kept for **30 days** while inactive, then deleted. Renew or switch to a free Postgres backup. | | **Free single-device** | Free-tier backups are device-bound; claiming a backup on a new device is limited to once per week. | --- diff --git a/resources/platform-icons/alloydb.svg b/resources/platform-icons/alloydb.svg index b7b4dcc..9578c0e 100644 --- a/resources/platform-icons/alloydb.svg +++ b/resources/platform-icons/alloydb.svg @@ -1 +1,41 @@ -Google Cloud \ No newline at end of file + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/resources/platform-icons/aurora.svg b/resources/platform-icons/aurora.svg new file mode 100644 index 0000000..1bbcc54 --- /dev/null +++ b/resources/platform-icons/aurora.svg @@ -0,0 +1 @@ +Amazon Aurora \ No newline at end of file diff --git a/resources/platform-icons/aws.svg b/resources/platform-icons/aws.svg index 932945e..3c542c2 100644 --- a/resources/platform-icons/aws.svg +++ b/resources/platform-icons/aws.svg @@ -1 +1 @@ -Amazon Web Services \ No newline at end of file +Amazon RDS \ No newline at end of file diff --git a/resources/platform-icons/azure.svg b/resources/platform-icons/azure.svg index 6c0d5a7..8d3d0dd 100644 --- a/resources/platform-icons/azure.svg +++ b/resources/platform-icons/azure.svg @@ -1 +1 @@ - +Icon-databases-131 \ No newline at end of file diff --git a/resources/platform-icons/googlecloud.svg b/resources/platform-icons/googlecloud.svg index b7b4dcc..561ba5e 100644 --- a/resources/platform-icons/googlecloud.svg +++ b/resources/platform-icons/googlecloud.svg @@ -1 +1,41 @@ -Google Cloud \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/resources/platform-icons/neon.svg b/resources/platform-icons/neon.svg index a704022..f89faf9 100644 --- a/resources/platform-icons/neon.svg +++ b/resources/platform-icons/neon.svg @@ -1,4 +1 @@ - - - - +Neon \ No newline at end of file diff --git a/resources/platform-icons/postgresql.svg b/resources/platform-icons/postgresql.svg index dcf75b7..87db410 100644 --- a/resources/platform-icons/postgresql.svg +++ b/resources/platform-icons/postgresql.svg @@ -1 +1 @@ -PostgreSQL \ No newline at end of file +PostgreSQL \ No newline at end of file diff --git a/resources/platform-icons/supabase.svg b/resources/platform-icons/supabase.svg index b773557..ea1cf3b 100644 --- a/resources/platform-icons/supabase.svg +++ b/resources/platform-icons/supabase.svg @@ -1 +1 @@ -Supabase \ No newline at end of file +Supabase \ No newline at end of file diff --git a/resources/platform-icons/timescale.svg b/resources/platform-icons/timescale.svg index b8e2234..0161b5e 100644 --- a/resources/platform-icons/timescale.svg +++ b/resources/platform-icons/timescale.svg @@ -1 +1 @@ -Timescale \ No newline at end of file +Timescale \ No newline at end of file diff --git a/src/features/settings/SettingsHubPanel.ts b/src/features/settings/SettingsHubPanel.ts index c9ce79f..843fd48 100644 --- a/src/features/settings/SettingsHubPanel.ts +++ b/src/features/settings/SettingsHubPanel.ts @@ -25,7 +25,7 @@ export interface SettingsHubShowOptions { /** Prefill the connection editor from a postgres:// URL. */ prefillConnectionUrl?: string; /** Deep-link sync hub sub-tab. */ - tab?: 'preview' | 'conflicts' | 'shares' | 'devices' | 'advanced'; + tab?: 'overview' | 'settings' | 'items' | 'preview' | 'conflicts' | 'shares' | 'devices' | 'advanced'; } const DEFAULT_SECTION: SettingsHubSection = 'connections'; diff --git a/src/features/settings/handlers/sync.ts b/src/features/settings/handlers/sync.ts index 95063c5..b3c5d42 100644 --- a/src/features/settings/handlers/sync.ts +++ b/src/features/settings/handlers/sync.ts @@ -123,6 +123,7 @@ export class SyncSectionHandler implements SettingsSectionHandler { break; case 'diagnostics': await SyncController.getInstance().runDiagnostics(); + this.sendState(); break; case 'stopSyncingItem': await this.stopSyncingItem(String(message.itemId ?? ''), String(message.itemName ?? '')); @@ -446,7 +447,6 @@ export class SyncSectionHandler implements SettingsSectionHandler { if (!(await requirePro(ProFeature.CloudBackup))) { return; } - this.host.post({ type: 'sync/running' }); const preview = await SyncController.getInstance().previewSync(transientExcludedIds); this.host.post({ type: 'sync/preview', preview: preview ?? null }); this.sendState(); diff --git a/src/features/sync/SyncController.ts b/src/features/sync/SyncController.ts index 7d403ff..2c46e80 100644 --- a/src/features/sync/SyncController.ts +++ b/src/features/sync/SyncController.ts @@ -28,6 +28,7 @@ import { SYNC_LAST_ERROR_KEY, SYNC_LAST_SYNC_AT_KEY, SYNC_PERIODIC_MS, + CLOUD_INACTIVE_RETENTION_DAYS, SYNC_PREVIEW_CACHE_KEY, } from './constants'; import type { @@ -66,6 +67,9 @@ export class SyncController implements vscode.Disposable { private backoffMs = SYNC_BACKOFF_INITIAL_MS; private status: SyncStatus = 'not_configured'; private conflictCount = 0; + private syncInFlight = false; + /** Serializes sync runs so manual Sync Now waits instead of no-oping. */ + private syncTail: Promise = Promise.resolve(); private statusBar: SyncStatusBar | undefined; private readonly disposables: vscode.Disposable[] = []; @@ -279,15 +283,13 @@ export class SyncController implements vscode.Disposable { return undefined; } - const direction = options.direction ?? 'both'; - const dryRun = !!options.dryRun; - if (!isSyncProviderAllowed(config.providerId)) { this.status = 'error'; this.updateStatusBar(); const tier = syncProviderMinTier(config.providerId); void vscode.window.showWarningMessage( - `Your current plan does not include the "${config.providerId}" sync backend (requires NexQL ${TIER_DISPLAY[tier]}). Your remote data is intact.`, + `Your current plan does not include the "${config.providerId}" sync backend (requires NexQL ${TIER_DISPLAY[tier]}). ` + + `NexQL Cloud data is kept for ${CLOUD_INACTIVE_RETENTION_DAYS} days while inactive, then deleted. Renew or switch to a free Postgres backup.`, 'View Plans', ).then((c) => { if (c === 'View Plans') { @@ -307,11 +309,28 @@ export class SyncController implements vscode.Disposable { } } + const run = () => this.runSyncLocked(options); + const outcome = this.syncTail.then(run, run); + this.syncTail = outcome.then(() => undefined, () => undefined); + return outcome as Promise; + } + + private async runSyncLocked(options: SyncRunOptions = {}): Promise { + const config = this.getConfig(); + const providerId = config.providerId; + if (!providerId) { + return undefined; + } + const direction = options.direction ?? 'both'; + const dryRun = !!options.dryRun; + const vault = VaultService.getInstance(); + + this.syncInFlight = true; this.status = 'syncing'; this.updateStatusBar(); const start = Date.now(); const deviceId = getOrCreateDeviceId(this.context); - const provider = this.createProvider(config.providerId); + const provider = this.createProvider(providerId); const index = new SyncIndex(this.context); try { @@ -375,14 +394,14 @@ export class SyncController implements vscode.Disposable { conflicts: merge.conflicts.length, skipped: merge.skipped.length, durationMs: Date.now() - start, - provider: config.providerId, + provider: providerId, summary, outgoing: previewLists.outgoing, incoming: previewLists.incoming, conflictItems: previewLists.conflictItems, }; await this.context.globalState.update(SYNC_PREVIEW_CACHE_KEY, preview); - this.status = merge.conflicts.length > 0 ? 'conflict' : this.status; + this.status = this.resolveIdleStatus(merge.conflicts.length); this.updateStatusBar(); return preview; } @@ -437,7 +456,9 @@ export class SyncController implements vscode.Disposable { this.purgeStaleNotebookIndex(index, updatedBase); await index.flush(); - SyncActivityLog.getInstance(this.context).acknowledge(syncedKeys); + const conflictIds = new Set(merge.conflicts.map((c) => c.id)); + const ackKeys = this.buildAcknowledgeKeys(syncedKeys, localItems, newBaseManifest, conflictIds); + SyncActivityLog.getInstance(this.context).acknowledge(ackKeys); await this.context.globalState.update(SYNC_LAST_CONFLICTS_KEY, merge.conflicts); this.conflictCount = merge.conflicts.length + this.countConflictCopies(); @@ -470,7 +491,7 @@ export class SyncController implements vscode.Disposable { conflicts: merge.conflicts.length, skipped, durationMs: Date.now() - start, - provider: config.providerId, + provider: providerId, summary, }; @@ -503,12 +524,14 @@ export class SyncController implements vscode.Disposable { TelemetryService.getInstance().trackEvent('sync_failure', { failureClass: isNetwork ? 'network' : 'other', - provider: config.providerId, + provider: providerId, }); this.outputChannel.appendLine(`sync: failed ${e instanceof Error ? e.message : String(e)}`); setTimeout(() => void this.runSync(), this.backoffMs); return undefined; + } finally { + this.syncInFlight = false; } } @@ -925,6 +948,44 @@ export class SyncController implements vscode.Disposable { }); } + /** Restore a non-in-progress status after preview or when sync cannot start. */ + private resolveIdleStatus(newConflictsInRun: number): SyncStatus { + const config = this.getConfig(); + if (config.paused) { + return 'paused'; + } + if (newConflictsInRun > 0 || this.conflictCount > 0) { + return 'conflict'; + } + return this.getLastSyncAt() ? 'synced' : 'idle'; + } + + /** + * Clear pending activities for items reconciled this run — including those + * already up-to-date (no push/pull transfer needed). + */ + private buildAcknowledgeKeys( + transferredKeys: Set, + localItems: Array<{ meta: SyncItemMeta }>, + newBaseManifest: SyncItemMeta[], + conflictIds: ReadonlySet, + ): Set { + const keys = new Set(transferredKeys); + const activeBase = new Set( + newBaseManifest.filter((m) => !m.deleted).map((m) => metaKey(m)), + ); + for (const { meta } of localItems) { + if (meta.deleted || conflictIds.has(meta.id)) { + continue; + } + const key = metaKey(meta); + if (activeBase.has(key)) { + keys.add(key); + } + } + return keys; + } + /** * Items known to sync on this device: base manifest (synced) plus local * index (not yet pushed). Names come from local sources only — the remote @@ -1087,10 +1148,25 @@ export class SyncController implements vscode.Disposable { const indexEntries = index.getAll(); const baseManifest = this.getBaseManifest(); const baseActiveIds = new Set(baseManifest.filter((m) => !m.deleted).map((m) => m.id)); + const pendingIds = new Set(this.listPendingActivities().map((p) => p.itemId)); const nbSvc = new NotebookSyncService(this.context, index); const byId = new Map(); + const resolveItemStatus = (id: string, isExcluded: boolean): SyncedItemView['itemStatus'] => { + if (isExcluded) { + return 'excluded'; + } + if (pendingIds.has(id)) { + return 'pending'; + } + if (baseActiveIds.has(id)) { + return 'synced'; + } + return 'local'; + }; + for (const meta of baseManifest) { + const isExcluded = excluded.has(meta.id); byId.set(meta.id, { id: meta.id, kind: meta.kind, @@ -1098,20 +1174,23 @@ export class SyncController implements vscode.Disposable { updatedAt: meta.updatedAt, deviceId: meta.deviceId, revision: meta.revision, - excluded: excluded.has(meta.id), + excluded: isExcluded, deleted: meta.deleted, + itemStatus: resolveItemStatus(meta.id, isExcluded), }); } for (const [id, entry] of Object.entries(indexEntries)) { if (!byId.has(id)) { + const isExcluded = excluded.has(id); byId.set(id, { id, kind: entry.kind, name: entry.name, updatedAt: entry.modifiedAt ?? entry.syncedAt, revision: entry.syncedRevision || undefined, - excluded: excluded.has(id), + excluded: isExcluded, deleted: false, + itemStatus: resolveItemStatus(id, isExcluded), }); } } diff --git a/src/features/sync/constants.ts b/src/features/sync/constants.ts index 67cfa0d..28d41a0 100644 --- a/src/features/sync/constants.ts +++ b/src/features/sync/constants.ts @@ -23,6 +23,8 @@ export const DEFAULT_NOTEBOOK_FOLDER = 'PgStudioNotebooks'; export const DEFAULT_SYNC_API_ENDPOINT = 'https://nexql.astrx.dev/api'; export const TOMBSTONE_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; +/** NexQL Cloud deletes remote blobs after this many days without paid sync access. */ +export const CLOUD_INACTIVE_RETENTION_DAYS = 30; export const MIN_COMPRESSION_BYTES = 256; export const SCRYPT_N = 32768; export const SCRYPT_R = 8; diff --git a/src/features/sync/types.ts b/src/features/sync/types.ts index 31cefd1..e892935 100644 --- a/src/features/sync/types.ts +++ b/src/features/sync/types.ts @@ -159,6 +159,8 @@ export interface SyncedItemView { revision?: number; excluded: boolean; deleted: boolean; + /** Per-item inclusion state for the settings table (not global sync-in-progress). */ + itemStatus: 'excluded' | 'pending' | 'synced' | 'local'; } export type SyncActivityAction = 'create' | 'update' | 'rename' | 'delete'; diff --git a/src/lib/platform/connectionPresets.ts b/src/lib/platform/connectionPresets.ts index c7e235d..ba3c11e 100644 --- a/src/lib/platform/connectionPresets.ts +++ b/src/lib/platform/connectionPresets.ts @@ -85,7 +85,7 @@ export const CONNECTION_PLATFORM_PRESETS: readonly ConnectionPresetDefinition[] { id: 'aurora', label: 'AWS Aurora PostgreSQL', - icon: 'aws', + icon: 'aurora', hint: 'Cluster writer endpoint recommended. SSL Mode require.', defaults: { port: 5432, sslmode: 'require', applicationName: 'PgStudio' }, hostPlaceholder: 'mycluster.cluster-xxx.region.rds.amazonaws.com', diff --git a/templates/settings-hub/index.html b/templates/settings-hub/index.html index e9907e0..d9b424c 100644 --- a/templates/settings-hub/index.html +++ b/templates/settings-hub/index.html @@ -584,6 +584,10 @@

Visibility

Cloud Sync

Encrypted sync of connections, saved queries and notebooks

+
Loading sync status…
@@ -606,91 +610,129 @@

Cloud Sync is not set up