diff --git a/api/_lib/handlers/sync-items.js b/api/_lib/handlers/sync-items.js deleted file mode 100644 index b17bf74..0000000 --- a/api/_lib/handlers/sync-items.js +++ /dev/null @@ -1,74 +0,0 @@ -// GET /api/sync/items/:itemId — fetch encrypted blob. -// PUT /api/sync/items/:itemId — upsert encrypted blob + metadata. - -const { authenticateBearer } = require('../sync-auth'); -const { getItemBlob, upsertItem, refreshAccountQuota } = require('../sync-db'); - -module.exports = async (req, res) => { - const itemId = req.query.itemId; - if (!itemId) { - return res.status(400).json({ error: 'itemId is required' }); - } - - let auth; - try { - auth = await authenticateBearer(req); - } catch (err) { - console.error('sync/items auth:', err); - return res.status(500).json({ error: 'Auth unavailable' }); - } - if (!auth) { - return res.status(401).json({ error: 'Unauthorized' }); - } - - if (req.method === 'GET') { - try { - const blob = await getItemBlob(auth.account_id, itemId); - if (!blob || blob.length === 0) { - return res.status(404).end(); - } - res.setHeader('Content-Type', 'application/octet-stream'); - return res.status(200).send(blob); - } catch (err) { - console.error('sync/items GET:', err); - return res.status(500).json({ error: 'Failed to load item' }); - } - } - - if (req.method === 'PUT') { - let body = req.body; - if (typeof body === 'string') { - try { - body = JSON.parse(body); - } catch { - return res.status(400).json({ error: 'Invalid JSON body' }); - } - } - if (!body || typeof body !== 'object') { - return res.status(400).json({ error: 'Item payload required' }); - } - - const blobB64 = body.blob; - if (typeof blobB64 !== 'string') { - return res.status(400).json({ error: 'blob (base64) is required' }); - } - - try { - await upsertItem(auth.account_id, itemId, { - kind: body.kind, - blob: Buffer.from(blobB64, 'base64'), - content_hash: body.content_hash, - revision: Number(body.revision) || 1, - device_id: String(body.device_id || ''), - deleted: !!body.deleted, - }); - await refreshAccountQuota(auth.account_id); - return res.status(204).end(); - } catch (err) { - console.error('sync/items PUT:', err); - return res.status(500).json({ error: 'Failed to save item' }); - } - } - - return res.status(405).json({ error: 'Method Not Allowed' }); -}; diff --git a/api/_lib/handlers/sync-keys.js b/api/_lib/handlers/sync-keys.js deleted file mode 100644 index 63b91dc..0000000 --- a/api/_lib/handlers/sync-keys.js +++ /dev/null @@ -1,62 +0,0 @@ -// GET /api/sync/keys?email= — fetch a team member's public key for sharing. -// POST /api/sync/keys { public_key } — register this account's public key. - -const { authenticateBearer } = require('../sync-auth'); -const { upsertIdentity, getPublicKey } = require('../sync-db'); - -module.exports = async (req, res) => { - let auth; - try { - auth = await authenticateBearer(req); - } catch (err) { - console.error('sync/keys auth:', err); - return res.status(500).json({ error: 'Auth unavailable' }); - } - if (!auth) { - return res.status(401).json({ error: 'Unauthorized' }); - } - if (!auth.email) { - return res.status(403).json({ error: 'Account email required for sharing' }); - } - - if (req.method === 'GET') { - const email = req.query?.email; - if (!email) { - return res.status(400).json({ error: 'email query parameter required' }); - } - try { - const publicKey = await getPublicKey(email); - if (!publicKey) { - return res.status(404).json({ error: 'No public key registered for that email' }); - } - return res.status(200).json({ email: String(email).trim().toLowerCase(), public_key: publicKey }); - } catch (err) { - console.error('sync/keys GET:', err); - return res.status(500).json({ error: 'Failed to load public key' }); - } - } - - if (req.method === 'POST') { - let body = req.body; - if (typeof body === 'string') { - try { - body = JSON.parse(body); - } catch { - return res.status(400).json({ error: 'Invalid JSON body' }); - } - } - const publicKey = body && body.public_key; - if (typeof publicKey !== 'string' || !publicKey) { - return res.status(400).json({ error: 'public_key is required' }); - } - try { - await upsertIdentity(auth.email, auth.account_id, publicKey); - return res.status(204).end(); - } catch (err) { - console.error('sync/keys POST:', err); - return res.status(500).json({ error: 'Failed to register public key' }); - } - } - - return res.status(405).json({ error: 'Method Not Allowed' }); -}; diff --git a/api/_lib/handlers/sync-manifest.js b/api/_lib/handlers/sync-manifest.js deleted file mode 100644 index d170b2a..0000000 --- a/api/_lib/handlers/sync-manifest.js +++ /dev/null @@ -1,70 +0,0 @@ -// GET /api/sync/manifest — list encrypted sync item metadata for the signed-in account. -// PUT /api/sync/manifest — upsert manifest metadata (blobs uploaded via /sync/items/:id). - -const { authenticateBearer } = require('../sync-auth'); -const { listManifest, upsertManifestMeta } = require('../sync-db'); - -function rowToEntry(row) { - return { - item_id: row.item_id, - kind: row.kind, - content_hash: row.content_hash, - revision: row.revision, - device_id: row.device_id, - deleted: row.deleted, - updated_at: row.updated_at instanceof Date - ? row.updated_at.toISOString() - : new Date(row.updated_at).toISOString(), - }; -} - -module.exports = async (req, res) => { - let auth; - try { - auth = await authenticateBearer(req); - } catch (err) { - console.error('sync/manifest auth:', err); - return res.status(500).json({ error: 'Auth unavailable' }); - } - if (!auth) { - return res.status(401).json({ error: 'Unauthorized' }); - } - - if (req.method === 'GET') { - try { - const since = req.query?.since ? Number(req.query.since) : undefined; - const rows = await listManifest(auth.account_id, Number.isFinite(since) ? since : undefined); - if (!rows.length) { - return res.status(404).end(); - } - return res.status(200).json(rows.map(rowToEntry)); - } catch (err) { - console.error('sync/manifest GET:', err); - return res.status(500).json({ error: 'Failed to load manifest' }); - } - } - - if (req.method === 'PUT') { - let body = req.body; - if (typeof body === 'string') { - try { - body = JSON.parse(body); - } catch { - return res.status(400).json({ error: 'Invalid JSON body' }); - } - } - if (!Array.isArray(body)) { - return res.status(400).json({ error: 'Manifest must be a JSON array' }); - } - - try { - await upsertManifestMeta(auth.account_id, body); - return res.status(204).end(); - } catch (err) { - console.error('sync/manifest PUT:', err); - return res.status(500).json({ error: 'Failed to save manifest' }); - } - } - - return res.status(405).json({ error: 'Method Not Allowed' }); -}; diff --git a/api/_lib/handlers/sync-shares-id.js b/api/_lib/handlers/sync-shares-id.js deleted file mode 100644 index 83b059b..0000000 --- a/api/_lib/handlers/sync-shares-id.js +++ /dev/null @@ -1,41 +0,0 @@ -// DELETE /api/sync/shares/:shareId — revoke a share you own. -// GET /api/sync/shares/:shareId — (owner) check a single share's status. - -const { authenticateBearer } = require('../sync-auth'); -const { revokeShare } = require('../sync-db'); - -module.exports = async (req, res) => { - const shareId = req.query.shareId; - if (!shareId) { - return res.status(400).json({ error: 'shareId is required' }); - } - - let auth; - try { - auth = await authenticateBearer(req); - } catch (err) { - console.error('sync/shares/:id auth:', err); - return res.status(500).json({ error: 'Auth unavailable' }); - } - if (!auth) { - return res.status(401).json({ error: 'Unauthorized' }); - } - if (!auth.email) { - return res.status(403).json({ error: 'Account email required for sharing' }); - } - - if (req.method === 'DELETE') { - try { - const revoked = await revokeShare(auth.email, shareId); - if (!revoked) { - return res.status(404).json({ error: 'Share not found or not owned by you' }); - } - return res.status(204).end(); - } catch (err) { - console.error('sync/shares/:id DELETE:', err); - return res.status(500).json({ error: 'Failed to revoke share' }); - } - } - - return res.status(405).json({ error: 'Method Not Allowed' }); -}; diff --git a/api/_lib/handlers/sync-shares.js b/api/_lib/handlers/sync-shares.js deleted file mode 100644 index 0a2ebbd..0000000 --- a/api/_lib/handlers/sync-shares.js +++ /dev/null @@ -1,110 +0,0 @@ -// GET /api/sync/shares — list shares granted to me (the signed-in grantee). -// POST /api/sync/shares { grantee_email, items:[{share_id,kind,name,share_blob,wrapped_key}] } -// — create shares from me (owner) to a grantee. - -const crypto = require('crypto'); -const { authenticateBearer } = require('../sync-auth'); -const { createShares, listSharesForGrantee, listSharesByOwner } = require('../sync-db'); - -const MAX_ITEMS_PER_REQUEST = 100; -const MAX_BLOB_CHARS = 2 * 1024 * 1024; // ~1.5MB binary after base64 - -module.exports = async (req, res) => { - let auth; - try { - auth = await authenticateBearer(req); - } catch (err) { - console.error('sync/shares auth:', err); - return res.status(500).json({ error: 'Auth unavailable' }); - } - if (!auth) { - return res.status(401).json({ error: 'Unauthorized' }); - } - if (!auth.email) { - return res.status(403).json({ error: 'Account email required for sharing' }); - } - - if (req.method === 'GET') { - try { - const direction = req.query?.direction; - if (direction === 'outgoing') { - const rows = await listSharesByOwner(auth.email); - return res.status(200).json( - rows.map((r) => ({ - share_id: r.share_id, - grantee_email: r.grantee_email, - item_kind: r.item_kind, - item_name: r.item_name, - created_at: r.created_at instanceof Date ? r.created_at.toISOString() : r.created_at, - revoked: r.revoked, - })), - ); - } - const rows = await listSharesForGrantee(auth.email); - return res.status(200).json( - rows.map((r) => ({ - share_id: r.share_id, - owner_email: r.owner_email, - kind: r.item_kind, - name: r.item_name, - share_blob: r.share_blob, - wrapped_key: r.wrapped_key, - created_at: r.created_at instanceof Date ? r.created_at.toISOString() : r.created_at, - })), - ); - } catch (err) { - console.error('sync/shares GET:', err); - return res.status(500).json({ error: 'Failed to list shares' }); - } - } - - if (req.method === 'POST') { - let body = req.body; - if (typeof body === 'string') { - try { - body = JSON.parse(body); - } catch { - return res.status(400).json({ error: 'Invalid JSON body' }); - } - } - const granteeEmail = body && body.grantee_email; - const items = body && body.items; - if (typeof granteeEmail !== 'string' || !granteeEmail) { - return res.status(400).json({ error: 'grantee_email is required' }); - } - if (!Array.isArray(items) || items.length === 0) { - return res.status(400).json({ error: 'items array is required' }); - } - if (items.length > MAX_ITEMS_PER_REQUEST) { - return res.status(413).json({ error: `Too many items (max ${MAX_ITEMS_PER_REQUEST})` }); - } - for (const item of items) { - if (!item || (item.kind !== 'query' && item.kind !== 'notebook')) { - return res.status(400).json({ error: 'Each item needs a shareable kind (query|notebook)' }); - } - if (typeof item.share_blob !== 'string' || typeof item.wrapped_key !== 'string') { - return res.status(400).json({ error: 'Each item needs share_blob and wrapped_key' }); - } - if (item.share_blob.length > MAX_BLOB_CHARS) { - return res.status(413).json({ error: 'Shared item too large' }); - } - } - - try { - const normalized = items.map((item) => ({ - share_id: typeof item.share_id === 'string' && item.share_id ? item.share_id : crypto.randomUUID(), - kind: item.kind, - name: typeof item.name === 'string' ? item.name.slice(0, 200) : null, - share_blob: item.share_blob, - wrapped_key: item.wrapped_key, - })); - const created = await createShares(auth.email, granteeEmail, normalized); - return res.status(201).json({ created }); - } catch (err) { - console.error('sync/shares POST:', err); - return res.status(500).json({ error: 'Failed to create shares' }); - } - } - - return res.status(405).json({ error: 'Method Not Allowed' }); -}; diff --git a/api/_lib/handlers/sync-v2-pull.js b/api/_lib/handlers/sync-v2-pull.js new file mode 100644 index 0000000..c8ad286 --- /dev/null +++ b/api/_lib/handlers/sync-v2-pull.js @@ -0,0 +1,36 @@ +// GET /api/sync/v2/pull?space=&since= +// Delta pull: everything in the space past the client cursor (upserts + deletes). + +const { authenticateBearer } = require('../sync-auth'); +const { pullDelta, assertSpaceMember } = require('../sync-db'); + +module.exports = async (req, res) => { + if (req.method !== 'GET') { + return res.status(405).json({ error: 'Method Not Allowed' }); + } + + let auth; + try { + auth = await authenticateBearer(req); + } catch (err) { + console.error('sync/v2/pull auth:', err); + return res.status(500).json({ error: 'Auth unavailable' }); + } + if (!auth) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const space = String(req.query?.space || auth.account_id); + const since = req.query?.since ? Number(req.query.since) : 0; + + try { + if (!(await assertSpaceMember(space, auth.email, auth.account_id, 'viewer'))) { + return res.status(403).json({ error: 'Not a member of this workspace' }); + } + const delta = await pullDelta(space, since); + return res.status(200).json(delta); + } catch (err) { + console.error('sync/v2/pull:', err); + return res.status(500).json({ error: 'Failed to pull' }); + } +}; diff --git a/api/_lib/handlers/sync-v2-push.js b/api/_lib/handlers/sync-v2-push.js new file mode 100644 index 0000000..8ae42b9 --- /dev/null +++ b/api/_lib/handlers/sync-v2-push.js @@ -0,0 +1,55 @@ +// POST /api/sync/v2/push +// Body: { space?, ops: [{ op:'upsert'|'delete', item_id, kind?, base_version, content_hash?, blob? }] } +// Atomic batch with per-item compare-and-swap. Returns accepted + rejected (with remote state). + +const { authenticateBearer } = require('../sync-auth'); +const { pushBatch, assertSpaceMember } = require('../sync-db'); + +const MAX_OPS = 500; + +module.exports = async (req, res) => { + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Method Not Allowed' }); + } + + let auth; + try { + auth = await authenticateBearer(req); + } catch (err) { + console.error('sync/v2/push auth:', err); + return res.status(500).json({ error: 'Auth unavailable' }); + } + if (!auth) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + let body = req.body; + if (typeof body === 'string') { + try { + body = JSON.parse(body); + } catch { + return res.status(400).json({ error: 'Invalid JSON body' }); + } + } + const ops = Array.isArray(body?.ops) ? body.ops : null; + if (!ops) { + return res.status(400).json({ error: 'ops array is required' }); + } + if (ops.length > MAX_OPS) { + return res.status(413).json({ error: `Too many ops (max ${MAX_OPS})` }); + } + + const space = String(body.space || auth.account_id); + const deviceId = req.headers['x-device-id'] || req.headers['X-Device-Id'] || ''; + + try { + if (!(await assertSpaceMember(space, auth.email, auth.account_id, 'editor'))) { + return res.status(403).json({ error: 'Write access required for this workspace' }); + } + const result = await pushBatch(space, deviceId, ops); + return res.status(200).json(result); + } catch (err) { + console.error('sync/v2/push:', err); + return res.status(500).json({ error: 'Failed to push' }); + } +}; diff --git a/api/_lib/handlers/sync-v2-reset.js b/api/_lib/handlers/sync-v2-reset.js new file mode 100644 index 0000000..275ed75 --- /dev/null +++ b/api/_lib/handlers/sync-v2-reset.js @@ -0,0 +1,43 @@ +// POST /api/sync/v2/reset Body: { space? } +// Wipe a space (owner only). Powers "clear cloud & push from local". + +const { authenticateBearer } = require('../sync-auth'); +const { resetSpace, assertSpaceMember } = require('../sync-db'); + +module.exports = async (req, res) => { + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Method Not Allowed' }); + } + + let auth; + try { + auth = await authenticateBearer(req); + } catch (err) { + console.error('sync/v2/reset auth:', err); + return res.status(500).json({ error: 'Auth unavailable' }); + } + if (!auth) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + let body = req.body; + if (typeof body === 'string') { + try { + body = JSON.parse(body); + } catch { + body = {}; + } + } + const space = String(body?.space || auth.account_id); + + try { + if (!(await assertSpaceMember(space, auth.email, auth.account_id, 'owner'))) { + return res.status(403).json({ error: 'Owner access required to reset this workspace' }); + } + await resetSpace(space); + return res.status(204).end(); + } catch (err) { + console.error('sync/v2/reset:', err); + return res.status(500).json({ error: 'Failed to reset' }); + } +}; diff --git a/api/_lib/handlers/sync-v2-spaces.js b/api/_lib/handlers/sync-v2-spaces.js new file mode 100644 index 0000000..75eef8a --- /dev/null +++ b/api/_lib/handlers/sync-v2-spaces.js @@ -0,0 +1,112 @@ +// GET /api/sync/v2/spaces → workspaces the caller belongs to +// GET /api/sync/v2/spaces?space=ID → members of a workspace +// POST /api/sync/v2/spaces → { action: 'create'|'addMember'|'removeMember', ... } +// Team workspaces require a singularity (Teams) license. + +const crypto = require('crypto'); +const { authenticateBearer } = require('../sync-auth'); +const { + createSpace, + listSpacesForEmail, + listMembers, + addMember, + removeMember, + assertSpaceMember, +} = require('../sync-db'); + +function isoize(rows, field) { + return rows.map((r) => ({ + ...r, + [field]: r[field] instanceof Date ? r[field].toISOString() : new Date(r[field]).toISOString(), + })); +} + +module.exports = async (req, res) => { + let auth; + try { + auth = await authenticateBearer(req); + } catch (err) { + console.error('sync/v2/spaces auth:', err); + return res.status(500).json({ error: 'Auth unavailable' }); + } + if (!auth) { + return res.status(401).json({ error: 'Unauthorized' }); + } + if (!auth.email) { + return res.status(400).json({ error: 'Account has no email; cannot manage workspaces' }); + } + + if (req.method === 'GET') { + try { + const space = req.query?.space ? String(req.query.space) : null; + if (space) { + if (!(await assertSpaceMember(space, auth.email, auth.account_id, 'viewer'))) { + return res.status(403).json({ error: 'Not a member of this workspace' }); + } + return res.status(200).json({ members: isoize(await listMembers(space), 'added_at') }); + } + return res.status(200).json({ spaces: await listSpacesForEmail(auth.email) }); + } catch (err) { + console.error('sync/v2/spaces GET:', err); + return res.status(500).json({ error: 'Failed to load workspaces' }); + } + } + + if (req.method === 'POST') { + if (auth.tier !== 'singularity') { + return res.status(402).json({ error: 'Team workspaces require a Teams license' }); + } + let body = req.body; + if (typeof body === 'string') { + try { + body = JSON.parse(body); + } catch { + return res.status(400).json({ error: 'Invalid JSON body' }); + } + } + const action = String(body?.action || ''); + + try { + if (action === 'create') { + const name = String(body.name || 'Shared workspace').slice(0, 120); + const spaceId = `ws_${crypto.randomBytes(12).toString('hex')}`; + await createSpace(spaceId, name, auth.email); + return res.status(200).json({ space_id: spaceId, name }); + } + + const space = String(body.space || ''); + if (!space) { + return res.status(400).json({ error: 'space is required' }); + } + if (!(await assertSpaceMember(space, auth.email, auth.account_id, 'owner'))) { + return res.status(403).json({ error: 'Owner access required' }); + } + + if (action === 'addMember') { + const email = String(body.email || ''); + const role = body.role === 'viewer' ? 'viewer' : 'editor'; + if (!email) { + return res.status(400).json({ error: 'email is required' }); + } + await addMember(space, email, role); + return res.status(204).end(); + } + + if (action === 'removeMember') { + const email = String(body.email || ''); + if (!email) { + return res.status(400).json({ error: 'email is required' }); + } + await removeMember(space, email); + return res.status(204).end(); + } + + return res.status(400).json({ error: 'Unknown action' }); + } catch (err) { + console.error('sync/v2/spaces POST:', err); + return res.status(500).json({ error: 'Failed to update workspace' }); + } + } + + return res.status(405).json({ error: 'Method Not Allowed' }); +}; 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..f1c3179 100644 --- a/api/_lib/sync-db.js +++ b/api/_lib/sync-db.js @@ -1,8 +1,15 @@ -// Neon Postgres pool for NexQL Cloud sync storage. +// Neon Postgres pool for NexQL Cloud sync storage (sync v2 — git-like). // Connection URL: DATABASE_URL, POSTGRES_URL, or Vercel-prefixed variants (see db-url.js). +// +// Model: each "space" is one sync stream. Personal space_id === account_id; +// shared spaces have a generated id + member roster. Every write stamps a row +// with a monotonic `version` drawn from cursor_seq. Pull returns everything past +// a client cursor (upserts + permanent deletes). Push is an atomic batch with +// per-item optimistic concurrency (compare-and-swap on version). const { neon } = require('@neondatabase/serverless'); const { resolveDatabaseUrl } = require('./db-url'); +const { CLOUD_INACTIVE_RETENTION_DAYS } = require('./sync-retention'); let sql = null; let schemaReady = null; @@ -22,66 +29,66 @@ async function ensureSchema() { if (!schemaReady) { schemaReady = (async () => { const db = getSql(); + await db`CREATE SCHEMA IF NOT EXISTS pgstudio_sync`; + await db`CREATE SEQUENCE IF NOT EXISTS pgstudio_sync.cursor_seq`; await db` - CREATE SCHEMA IF NOT EXISTS pgstudio_sync - `; - await db` - CREATE TABLE IF NOT EXISTS pgstudio_sync.sync_items ( - account_id TEXT NOT NULL, + CREATE TABLE IF NOT EXISTS pgstudio_sync.items_v2 ( + space_id TEXT NOT NULL, item_id TEXT NOT NULL, - kind TEXT NOT NULL CHECK (kind IN ('connection','query','notebook','secrets')), - blob BYTEA NOT NULL DEFAULT ''::bytea, + kind TEXT NOT NULL CHECK (kind IN ('connection','query','notebook')), + blob BYTEA NOT NULL, content_hash TEXT NOT NULL, - revision INT NOT NULL DEFAULT 1, + version BIGINT NOT NULL, device_id TEXT NOT NULL, - deleted BOOLEAN NOT NULL DEFAULT false, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (account_id, item_id) + PRIMARY KEY (space_id, item_id) ) `; await db` - CREATE INDEX IF NOT EXISTS sync_items_pull_idx - ON pgstudio_sync.sync_items (account_id, updated_at) + CREATE INDEX IF NOT EXISTS items_v2_cursor_idx + ON pgstudio_sync.items_v2 (space_id, version) `; - // Per-user X25519 public keys, keyed by email (team sharing identity). + // Permanent delete log — never pruned. Stops deleted items resurrecting. await db` - CREATE TABLE IF NOT EXISTS pgstudio_sync.sync_identities ( - email TEXT PRIMARY KEY, - account_id TEXT NOT NULL, - public_key TEXT NOT NULL, - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + CREATE TABLE IF NOT EXISTS pgstudio_sync.deletes_v2 ( + space_id TEXT NOT NULL, + item_id TEXT NOT NULL, + version BIGINT NOT NULL, + deleted_by TEXT NOT NULL, + deleted_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (space_id, item_id) ) `; - // Shared items: each row is one item sealed to one grantee. await db` - CREATE TABLE IF NOT EXISTS pgstudio_sync.sync_shares ( - share_id TEXT PRIMARY KEY, - owner_email TEXT NOT NULL, - grantee_email TEXT NOT NULL, - item_kind TEXT NOT NULL CHECK (item_kind IN ('query','notebook')), - item_name TEXT, - share_blob TEXT NOT NULL, - wrapped_key TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - revoked BOOLEAN NOT NULL DEFAULT false - ) + CREATE INDEX IF NOT EXISTS deletes_v2_cursor_idx + ON pgstudio_sync.deletes_v2 (space_id, version) `; + // Shared workspaces (team sharing). Personal space rows are implicit. await db` - CREATE INDEX IF NOT EXISTS sync_shares_grantee_idx - ON pgstudio_sync.sync_shares (grantee_email) WHERE revoked = false + CREATE TABLE IF NOT EXISTS pgstudio_sync.spaces ( + space_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + owner_email TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) `; await db` - CREATE INDEX IF NOT EXISTS sync_shares_owner_idx - ON pgstudio_sync.sync_shares (owner_email) + CREATE TABLE IF NOT EXISTS pgstudio_sync.space_members ( + space_id TEXT NOT NULL REFERENCES pgstudio_sync.spaces(space_id) ON DELETE CASCADE, + email TEXT NOT NULL, + role TEXT NOT NULL CHECK (role IN ('owner','editor','viewer')), + added_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (space_id, email) + ) `; 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` @@ -106,186 +113,260 @@ function normalizeEmail(email) { return String(email || '').trim().toLowerCase(); } -async function upsertIdentity(email, accountId, publicKey) { - await ensureSchema(); +function toBuffer(blob) { + return Buffer.isBuffer(blob) ? blob : Buffer.from(blob); +} + +// ── Delta sync ────────────────────────────────────────────────────────────── + +/** Highest version stamped in a space across items + deletes (the sync cursor). */ +async function spaceCursor(spaceId) { const db = getSql(); - await db` - INSERT INTO pgstudio_sync.sync_identities (email, account_id, public_key, updated_at) - VALUES (${normalizeEmail(email)}, ${accountId}, ${publicKey}, now()) - ON CONFLICT (email) DO UPDATE SET - account_id = EXCLUDED.account_id, - public_key = EXCLUDED.public_key, - updated_at = now() + const rows = await db` + SELECT GREATEST( + COALESCE((SELECT MAX(version) FROM pgstudio_sync.items_v2 WHERE space_id = ${spaceId}), 0), + COALESCE((SELECT MAX(version) FROM pgstudio_sync.deletes_v2 WHERE space_id = ${spaceId}), 0) + ) AS cursor `; + return Number(rows[0]?.cursor || 0); } -async function getPublicKey(email) { +/** Everything changed in a space since `since` (0 = full snapshot). Blobs inline (base64). */ +async function pullDelta(spaceId, since) { await ensureSchema(); const db = getSql(); - const rows = await db` - SELECT public_key FROM pgstudio_sync.sync_identities - WHERE email = ${normalizeEmail(email)} LIMIT 1 + const sinceVersion = Number.isFinite(since) ? Number(since) : 0; + + const itemRows = await db` + SELECT item_id, kind, content_hash, version, device_id, + encode(blob, 'base64') AS blob, + updated_at + FROM pgstudio_sync.items_v2 + WHERE space_id = ${spaceId} AND version > ${sinceVersion} + ORDER BY version ASC `; - return rows.length ? rows[0].public_key : null; + const deleteRows = await db` + SELECT item_id, version + FROM pgstudio_sync.deletes_v2 + WHERE space_id = ${spaceId} AND version > ${sinceVersion} + ORDER BY version ASC + `; + const cursor = await spaceCursor(spaceId); + + return { + cursor, + upserts: itemRows.map((r) => ({ + item_id: r.item_id, + kind: r.kind, + content_hash: r.content_hash, + version: Number(r.version), + device_id: r.device_id, + blob: r.blob, + updated_at: r.updated_at instanceof Date ? r.updated_at.toISOString() : new Date(r.updated_at).toISOString(), + })), + deletes: deleteRows.map((r) => r.item_id), + }; } -async function createShares(ownerEmail, granteeEmail, items) { +/** + * Atomic batch push with per-item optimistic concurrency. + * + * Each op carries `base_version` (the version the client last saw). An upsert is + * accepted when the server row is unchanged since (`version <= base_version`) or + * the content is identical (idempotent). Otherwise it is rejected and the server + * row is returned so the client can resolve last-writer-wins, then re-push. + * A delete is accepted when the row is unchanged since base_version, or absent. + * + * The whole batch runs in one transaction: the cursor advances all-or-nothing. + */ +async function pushBatch(spaceId, deviceId, ops) { await ensureSchema(); const db = getSql(); - const owner = normalizeEmail(ownerEmail); - const grantee = normalizeEmail(granteeEmail); - const created = []; - for (const item of items) { - const shareId = item.share_id; - await db` - INSERT INTO pgstudio_sync.sync_shares ( - share_id, owner_email, grantee_email, item_kind, item_name, share_blob, wrapped_key - ) VALUES ( - ${shareId}, ${owner}, ${grantee}, ${item.kind}, ${item.name || null}, - ${item.share_blob}, ${item.wrapped_key} + const device = String(deviceId || ''); + + const queries = ops.map((op) => { + const baseVersion = Number(op.base_version) || 0; + if (op.op === 'delete') { + return db` + WITH existing AS ( + SELECT version FROM pgstudio_sync.items_v2 + WHERE space_id = ${spaceId} AND item_id = ${op.item_id} + ), del AS ( + DELETE FROM pgstudio_sync.items_v2 + WHERE space_id = ${spaceId} AND item_id = ${op.item_id} AND version <= ${baseVersion} + RETURNING item_id + ), logged AS ( + INSERT INTO pgstudio_sync.deletes_v2 (space_id, item_id, version, deleted_by, deleted_at) + SELECT ${spaceId}, ${op.item_id}, nextval('pgstudio_sync.cursor_seq'), ${device}, now() + WHERE EXISTS (SELECT 1 FROM del) OR NOT EXISTS (SELECT 1 FROM existing) + ON CONFLICT (space_id, item_id) DO UPDATE + SET version = nextval('pgstudio_sync.cursor_seq'), deleted_by = EXCLUDED.deleted_by, deleted_at = now() + RETURNING version + ) + SELECT ${op.item_id} AS item_id, + (SELECT version FROM logged) AS new_version, + (SELECT version FROM existing) AS remote_version, + NULL::text AS remote_hash + `; + } + const blob = Buffer.from(String(op.blob || ''), 'base64'); + return db` + WITH existing AS ( + SELECT version, content_hash FROM pgstudio_sync.items_v2 + WHERE space_id = ${spaceId} AND item_id = ${op.item_id} + ), up AS ( + INSERT INTO pgstudio_sync.items_v2 + (space_id, item_id, kind, blob, content_hash, version, device_id, updated_at) + VALUES + (${spaceId}, ${op.item_id}, ${op.kind}, ${blob}, ${op.content_hash}, + nextval('pgstudio_sync.cursor_seq'), ${device}, now()) + ON CONFLICT (space_id, item_id) DO UPDATE + SET kind = EXCLUDED.kind, blob = EXCLUDED.blob, content_hash = EXCLUDED.content_hash, + version = nextval('pgstudio_sync.cursor_seq'), device_id = EXCLUDED.device_id, updated_at = now() + WHERE pgstudio_sync.items_v2.version <= ${baseVersion} + OR pgstudio_sync.items_v2.content_hash = EXCLUDED.content_hash + RETURNING version ) - ON CONFLICT (share_id) DO UPDATE SET - item_kind = EXCLUDED.item_kind, - item_name = EXCLUDED.item_name, - share_blob = EXCLUDED.share_blob, - wrapped_key = EXCLUDED.wrapped_key, - revoked = false + SELECT ${op.item_id} AS item_id, + (SELECT version FROM up) AS new_version, + (SELECT version FROM existing) AS remote_version, + (SELECT content_hash FROM existing) AS remote_hash `; - created.push(shareId); + }); + + const results = queries.length ? await db.transaction(queries) : []; + const accepted = []; + const rejected = []; + results.forEach((rows, i) => { + const row = Array.isArray(rows) ? rows[0] : rows; + const op = ops[i]; + if (row && row.new_version != null) { + accepted.push({ item_id: op.item_id, version: Number(row.new_version) }); + } else { + rejected.push({ + item_id: op.item_id, + remote_version: row && row.remote_version != null ? Number(row.remote_version) : null, + remote_hash: row ? row.remote_hash : null, + }); + } + }); + + const cursor = await spaceCursor(spaceId); + if (spaceId) { + await refreshAccountQuota(spaceId); } - return created; + return { cursor, accepted, rejected }; } -async function listSharesForGrantee(granteeEmail) { +/** Wipe a space (items + deletes). Powers "clear cloud & push". */ +async function resetSpace(spaceId) { await ensureSchema(); const db = getSql(); - return db` - SELECT share_id, owner_email, item_kind, item_name, share_blob, wrapped_key, created_at - FROM pgstudio_sync.sync_shares - WHERE grantee_email = ${normalizeEmail(granteeEmail)} AND revoked = false - ORDER BY created_at DESC - `; + await db`DELETE FROM pgstudio_sync.items_v2 WHERE space_id = ${spaceId}`; + await db`DELETE FROM pgstudio_sync.deletes_v2 WHERE space_id = ${spaceId}`; + await refreshAccountQuota(spaceId); } -async function listSharesByOwner(ownerEmail) { +// ── Shared workspaces ───────────────────────────────────────────────────────── + +async function createSpace(spaceId, name, ownerEmail) { await ensureSchema(); const db = getSql(); - return db` - SELECT share_id, grantee_email, item_kind, item_name, created_at, revoked - FROM pgstudio_sync.sync_shares - WHERE owner_email = ${normalizeEmail(ownerEmail)} - ORDER BY created_at DESC + const owner = normalizeEmail(ownerEmail); + await db` + INSERT INTO pgstudio_sync.spaces (space_id, name, owner_email) + VALUES (${spaceId}, ${name}, ${owner}) + ON CONFLICT (space_id) DO UPDATE SET name = EXCLUDED.name + `; + await db` + INSERT INTO pgstudio_sync.space_members (space_id, email, role) + VALUES (${spaceId}, ${owner}, 'owner') + ON CONFLICT (space_id, email) DO UPDATE SET role = 'owner' `; } -async function revokeShare(ownerEmail, shareId) { +async function listSpacesForEmail(email) { await ensureSchema(); const db = getSql(); - const rows = await db` - UPDATE pgstudio_sync.sync_shares - SET revoked = true - WHERE share_id = ${shareId} AND owner_email = ${normalizeEmail(ownerEmail)} - RETURNING share_id + return db` + SELECT s.space_id, s.name, s.owner_email, m.role + FROM pgstudio_sync.space_members m + JOIN pgstudio_sync.spaces s ON s.space_id = m.space_id + WHERE m.email = ${normalizeEmail(email)} + ORDER BY s.created_at ASC `; - return rows.length > 0; } -async function listManifest(accountId, sinceRevision) { +async function listMembers(spaceId) { await ensureSchema(); const db = getSql(); - if (sinceRevision) { - return db` - SELECT item_id, kind, content_hash, revision, device_id, deleted, updated_at - FROM pgstudio_sync.sync_items - WHERE account_id = ${accountId} AND revision > ${sinceRevision} - ORDER BY updated_at ASC - `; - } return db` - SELECT item_id, kind, content_hash, revision, device_id, deleted, updated_at - FROM pgstudio_sync.sync_items - WHERE account_id = ${accountId} - ORDER BY updated_at ASC + SELECT email, role, added_at FROM pgstudio_sync.space_members + WHERE space_id = ${spaceId} ORDER BY added_at ASC `; } -async function getItemBlob(accountId, itemId) { +async function addMember(spaceId, email, role) { await ensureSchema(); const db = getSql(); - const rows = await db` - SELECT blob FROM pgstudio_sync.sync_items - WHERE account_id = ${accountId} AND item_id = ${itemId} - LIMIT 1 + await db` + INSERT INTO pgstudio_sync.space_members (space_id, email, role) + VALUES (${spaceId}, ${normalizeEmail(email)}, ${role}) + ON CONFLICT (space_id, email) DO UPDATE SET role = EXCLUDED.role `; - if (!rows.length) { - return null; - } - const blob = rows[0].blob; - return Buffer.isBuffer(blob) ? blob : Buffer.from(blob); } -async function upsertItem(accountId, itemId, fields) { +async function removeMember(spaceId, email) { await ensureSchema(); const db = getSql(); - const blob = fields.blob ?? Buffer.alloc(0); await db` - INSERT INTO pgstudio_sync.sync_items ( - account_id, item_id, kind, blob, content_hash, revision, device_id, deleted, updated_at - ) VALUES ( - ${accountId}, - ${itemId}, - ${fields.kind}, - ${blob}, - ${fields.content_hash}, - ${fields.revision}, - ${fields.device_id}, - ${fields.deleted}, - now() - ) - ON CONFLICT (account_id, item_id) DO UPDATE SET - kind = EXCLUDED.kind, - blob = CASE WHEN EXCLUDED.blob = ''::bytea THEN pgstudio_sync.sync_items.blob ELSE EXCLUDED.blob END, - content_hash = EXCLUDED.content_hash, - revision = EXCLUDED.revision, - device_id = EXCLUDED.device_id, - deleted = EXCLUDED.deleted, - updated_at = now() + DELETE FROM pgstudio_sync.space_members + WHERE space_id = ${spaceId} AND email = ${normalizeEmail(email)} AND role <> 'owner' `; } -async function upsertManifestMeta(accountId, entries) { +const ROLE_RANK = { viewer: 1, editor: 2, owner: 3 }; + +/** Resolve the caller's role in a space. Personal space (id === account_id) is always owner. */ +async function memberRole(spaceId, email, accountId) { + if (spaceId === accountId) { + return 'owner'; + } await ensureSchema(); - for (const entry of entries) { - await upsertItem(accountId, entry.id, { - kind: entry.kind, - blob: Buffer.alloc(0), - content_hash: entry.contentHash, - revision: entry.revision, - device_id: entry.deviceId, - deleted: !!entry.deleted, - }); + const db = getSql(); + const rows = await db` + SELECT role FROM pgstudio_sync.space_members + WHERE space_id = ${spaceId} AND email = ${normalizeEmail(email)} LIMIT 1 + `; + return rows.length ? rows[0].role : null; +} + +/** True when the caller holds at least `minRole` in the space. */ +async function assertSpaceMember(spaceId, email, accountId, minRole) { + const role = await memberRole(spaceId, email, accountId); + if (!role) { + return false; } - await refreshAccountQuota(accountId); + return ROLE_RANK[role] >= ROLE_RANK[minRole]; } +// ── Quota / tier / devices ──────────────────────────────────────────────────── + async function refreshAccountQuota(accountId) { await ensureSchema(); const db = getSql(); const rows = await db` - SELECT - COALESCE(SUM(octet_length(blob)), 0)::bigint AS bytes_used, - COUNT(*) FILTER (WHERE NOT deleted)::int AS item_count - FROM pgstudio_sync.sync_items - WHERE account_id = ${accountId} + SELECT COALESCE(SUM(octet_length(blob)), 0)::bigint AS bytes_used, + COUNT(*)::int AS item_count + FROM pgstudio_sync.items_v2 + WHERE space_id = ${accountId} `; const stats = rows[0] || { bytes_used: 0, item_count: 0 }; await db` INSERT INTO pgstudio_sync.sync_accounts (account_id, bytes_used, item_count, updated_at) VALUES (${accountId}, ${stats.bytes_used}, ${stats.item_count}, now()) ON CONFLICT (account_id) DO UPDATE SET - bytes_used = EXCLUDED.bytes_used, - item_count = EXCLUDED.item_count, - updated_at = now() + bytes_used = EXCLUDED.bytes_used, item_count = EXCLUDED.item_count, updated_at = now() `; } @@ -294,17 +375,13 @@ async function getAccountQuota(accountId) { const db = getSql(); const rows = await db` SELECT tier, bytes_used, item_count, updated_at - FROM pgstudio_sync.sync_accounts - WHERE account_id = ${accountId} - LIMIT 1 + FROM pgstudio_sync.sync_accounts WHERE account_id = ${accountId} LIMIT 1 `; if (!rows.length) { await refreshAccountQuota(accountId); const again = await db` SELECT tier, bytes_used, item_count, updated_at - FROM pgstudio_sync.sync_accounts - WHERE account_id = ${accountId} - LIMIT 1 + FROM pgstudio_sync.sync_accounts WHERE account_id = ${accountId} LIMIT 1 `; return again[0] || { tier: 'sponsor', bytes_used: 0, item_count: 0, updated_at: new Date() }; } @@ -317,9 +394,7 @@ async function setAccountTier(accountId, tier) { await db` INSERT INTO pgstudio_sync.sync_accounts (account_id, tier, updated_at) VALUES (${accountId}, ${tier}, now()) - ON CONFLICT (account_id) DO UPDATE SET - tier = EXCLUDED.tier, - updated_at = now() + ON CONFLICT (account_id) DO UPDATE SET tier = EXCLUDED.tier, updated_at = now() `; } @@ -342,10 +417,8 @@ async function listDevices(accountId) { await ensureSchema(); const db = getSql(); return db` - SELECT device_id, device_name, last_seen - FROM pgstudio_sync.sync_devices - WHERE account_id = ${accountId} - ORDER BY last_seen DESC + SELECT device_id, device_name, last_seen FROM pgstudio_sync.sync_devices + WHERE account_id = ${accountId} ORDER BY last_seen DESC `; } @@ -354,28 +427,99 @@ async function revokeDevice(accountId, deviceId) { const db = getSql(); const rows = await db` DELETE FROM pgstudio_sync.sync_devices - WHERE account_id = ${accountId} AND device_id = ${deviceId} - RETURNING device_id + WHERE account_id = ${accountId} AND device_id = ${deviceId} RETURNING device_id `; return rows.length > 0; } +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() + `; +} + +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.items_v2 WHERE space_id = ${accountId}`; + await db`DELETE FROM pgstudio_sync.deletes_v2 WHERE space_id = ${accountId}`; + await db`DELETE FROM pgstudio_sync.sync_devices WHERE account_id = ${accountId}`; + if (ownerEmail) { + const email = normalizeEmail(ownerEmail); + await db`DELETE FROM pgstudio_sync.spaces 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, - getItemBlob, - upsertItem, - upsertManifestMeta, - upsertIdentity, - getPublicKey, - createShares, - listSharesForGrantee, - listSharesByOwner, - revokeShare, + pullDelta, + pushBatch, + resetSpace, + spaceCursor, + createSpace, + listSpacesForEmail, + listMembers, + addMember, + removeMember, + memberRole, + assertSpaceMember, refreshAccountQuota, getAccountQuota, setAccountTier, upsertDevice, listDevices, revokeDevice, + markAccountInactive, + markAccountActive, + deleteAccountCloudData, + 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/sync/[...path].js b/api/sync/[...path].js index 7949a4b..994d505 100644 --- a/api/sync/[...path].js +++ b/api/sync/[...path].js @@ -2,35 +2,32 @@ const { catchAllSegments } = require('../_lib/catch-all-route'); -const manifest = require('../_lib/handlers/sync-manifest'); -const items = require('../_lib/handlers/sync-items'); -const shares = require('../_lib/handlers/sync-shares'); -const sharesById = require('../_lib/handlers/sync-shares-id'); -const keys = require('../_lib/handlers/sync-keys'); +const v2Pull = require('../_lib/handlers/sync-v2-pull'); +const v2Push = require('../_lib/handlers/sync-v2-push'); +const v2Reset = require('../_lib/handlers/sync-v2-reset'); +const v2Spaces = require('../_lib/handlers/sync-v2-spaces'); const quota = require('../_lib/handlers/sync-quota'); const devices = require('../_lib/handlers/sync-devices'); module.exports = async (req, res) => { const segments = catchAllSegments(req, 'path', 'sync'); - const [head, id] = segments; + const [head, sub, id] = segments; - if (head === 'manifest' && segments.length === 1) { - return manifest(req, res); - } - if (head === 'items' && segments.length === 2) { - req.query.itemId = id; - return items(req, res); - } - if (head === 'shares' && segments.length === 1) { - return shares(req, res); - } - if (head === 'shares' && segments.length === 2) { - req.query.shareId = id; - return sharesById(req, res); - } - if (head === 'keys' && segments.length === 1) { - return keys(req, res); + if (head === 'v2') { + if (sub === 'pull' && segments.length === 2) { + return v2Pull(req, res); + } + if (sub === 'push' && segments.length === 2) { + return v2Push(req, res); + } + if (sub === 'reset' && segments.length === 2) { + return v2Reset(req, res); + } + if (sub === 'spaces' && segments.length === 2) { + return v2Spaces(req, res); + } } + if (head === 'quota' && segments.length === 1) { return quota(req, res); } @@ -38,7 +35,7 @@ module.exports = async (req, res) => { return devices(req, res); } if (head === 'devices' && segments.length === 2) { - req.query.deviceId = id; + req.query.deviceId = sub; return devices(req, res); } 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/package.json b/package.json index a816427..12ce77a 100644 --- a/package.json +++ b/package.json @@ -105,7 +105,7 @@ }, { "command": "postgres-explorer.sync.showSecretKey", - "title": "Show Sync Secret Key", + "title": "Privacy & Security Info", "category": "NexQL Sync" }, { @@ -120,12 +120,12 @@ }, { "command": "postgres-explorer.sync.share", - "title": "Share Items with a Team Member", + "title": "Share to Workspace…", "category": "NexQL Sync" }, { "command": "postgres-explorer.sync.importShares", - "title": "Import Shared Items", + "title": "Manage Workspaces…", "category": "NexQL Sync" }, { @@ -2060,24 +2060,6 @@ "scope": "machine", "description": "Base URL for NexQL account and cloud sync API." }, - "postgresExplorer.sync.githubClientId": { - "type": "string", - "default": "Ov23liPLACEHOLDER_GITHUB_CLIENT", - "scope": "machine", - "markdownDescription": "GitHub OAuth app client ID for device-flow fallback when built-in GitHub auth is unavailable. Register at https://github.com/settings/developers (enable device flow)." - }, - "postgresExplorer.sync.onedriveClientId": { - "type": "string", - "default": "00000000-0000-0000-0000-PLACEHOLDER", - "scope": "machine", - "markdownDescription": "Entra (Azure AD) public client app ID for OneDrive appFolder sync. Register at https://portal.azure.com." - }, - "postgresExplorer.sync.googleClientId": { - "type": "string", - "default": "000000000000-placeholder.apps.googleusercontent.com", - "scope": "machine", - "markdownDescription": "Google OAuth client ID for Drive appdata sync (loopback PKCE). Requires Google verification for production." - }, "postgresExplorer.sync.postgresConnectionId": { "type": "string", "default": "", 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..6359de5 100644 --- a/src/features/settings/handlers/sync.ts +++ b/src/features/settings/handlers/sync.ts @@ -1,8 +1,7 @@ import * as vscode from 'vscode'; import { SyncController } from '../../sync/SyncController'; import { SyncSetupWizard } from '../../sync/SyncSetupWizard'; -import { ConflictResolutionService } from '../../sync/ConflictResolutionService'; -import { SharingService } from '../../sync/SharingService'; +import { WorkspaceSharingService } from '../../sync/WorkspaceSharingService'; import { LicenseService } from '../../../services/LicenseService'; import { allowedSyncProviders, @@ -12,11 +11,11 @@ import { TIER_DISPLAY, } from '../../../services/featureGates'; import type { SettingsHubHostContext, SettingsHubMessage, SettingsSectionHandler } from '../types'; +import { ConnectionUtils } from '../../../utils/connectionUtils'; +import { DatabaseTreeItem } from '../../../providers/DatabaseTreeProvider'; +import { cmdNewNotebook } from '../../../commands/notebook'; const PROVIDER_LABELS: Record = { - gist: 'GitHub Gist', - onedrive: 'OneDrive', - gdrive: 'Google Drive', cloud: 'NexQL Cloud', postgres: 'Shared Postgres', }; @@ -70,14 +69,14 @@ export class SyncSectionHandler implements SettingsSectionHandler { case 'wizardTestBackend': await this.wizardTestBackend(String(message.providerId ?? 'cloud')); break; - case 'wizardVault': - await this.wizardVault(message); - break; case 'wizardComplete': await this.wizardComplete(message); break; - case 'wizardRecoveryKit': - await this.wizardRecoveryKit(message); + case 'savePostgresConnection': + await this.savePostgresConnection(String(message.postgresConnectionId ?? '')); + break; + case 'openNotebook': + await this.openNotebook(String(message.postgresConnectionId ?? '')); break; case 'saveFlags': await this.saveFlags(message.flags as Record); @@ -123,6 +122,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 ?? '')); @@ -161,19 +161,6 @@ export class SyncSectionHandler implements SettingsSectionHandler { this.host.post({ type: 'sync/wizardBackendResult', providerId, ...result }); } - private async wizardVault(message: SettingsHubMessage): Promise { - const wizard = new SyncSetupWizard(this.host.extensionContext); - const result = await wizard.setupVault( - message.mode === 'unlock' ? 'unlock' : 'create', - message.secretKey ? String(message.secretKey) : undefined, - { - passphrase: message.passphrase ? String(message.passphrase) : undefined, - legacyEmail: message.legacyEmail ? String(message.legacyEmail) : undefined, - }, - ); - this.host.post({ type: 'sync/wizardVaultResult', ...result }); - } - private async wizardComplete(message: SettingsHubMessage): Promise { const wizard = new SyncSetupWizard(this.host.extensionContext); const flags = (message.flags ?? {}) as Record; @@ -183,23 +170,14 @@ export class SyncSectionHandler implements SettingsSectionHandler { syncConnections: flags.syncConnections !== false, syncQueries: flags.syncQueries !== false, syncNotebooks: flags.syncNotebooks !== false, - syncPasswords: !!flags.syncPasswords, }, - message.vaultMode === 'unlock' ? 'unlock' : 'create', + message.postgresConnectionId ? String(message.postgresConnectionId) : undefined, ); this.host.post({ type: 'sync/wizardCompleteResult', ...result }); this.sendState(); this.sendItems(); } - private async wizardRecoveryKit(message: SettingsHubMessage): Promise { - await new SyncSetupWizard(this.host.extensionContext).exportRecoveryKit( - String(message.generation ?? ''), - String(message.secretKey ?? ''), - !!message.customPassphrase, - ); - } - private sendItems(): void { this.host.post({ type: 'sync/items', @@ -224,26 +202,12 @@ export class SyncSectionHandler implements SettingsSectionHandler { } private sendConflicts(): void { - const service = new ConflictResolutionService(this.host.extensionContext); - this.host.post({ type: 'sync/conflicts', conflicts: service.listConflicts() }); + // v2 resolves conflicts automatically (last-writer-wins, loser backed up + // locally), so there is no manual conflict queue. + this.host.post({ type: 'sync/conflicts', conflicts: [] }); } - private async resolveConflict(message: SettingsHubMessage): Promise { - const service = new ConflictResolutionService(this.host.extensionContext); - const id = String(message.conflictId ?? ''); - const action = String(message.resolveAction ?? ''); - if (action === 'keepMine') { - await service.resolveKeepMine(id); - } else if (action === 'keepTheirs') { - await service.resolveKeepTheirs(id); - } else if (action === 'keepBoth') { - await service.resolveKeepBoth(id, String(message.newName ?? `${id}-copy`)); - } else if (action === 'delete') { - await service.deleteConflictCopy(id); - } else if (action === 'diff') { - await service.openDiff(id); - return; - } + private async resolveConflict(_message: SettingsHubMessage): Promise { this.sendConflicts(); this.sendItems(); this.sendState(); @@ -251,31 +215,21 @@ export class SyncSectionHandler implements SettingsSectionHandler { private async sendShares(): Promise { try { - const service = new SharingService(this.host.extensionContext); - const [incoming, outgoing] = await Promise.all([ - service.listIncomingShares(), - service.listOutgoingShares(), - ]); - this.host.post({ type: 'sync/shares', incoming, outgoing }); + const workspaces = await new WorkspaceSharingService(this.host.extensionContext).listWorkspaces(); + this.host.post({ type: 'sync/shares', incoming: [], outgoing: [], workspaces }); } catch (e) { this.host.post({ type: 'sync/shares', incoming: [], outgoing: [], + workspaces: [], error: e instanceof Error ? e.message : String(e), }); } } - private async revokeShare(shareId: string): Promise { - if (!shareId) { - return; - } - try { - await new SharingService(this.host.extensionContext).revokeShare(shareId); - } catch (e) { - void vscode.window.showErrorMessage(`Revoke failed: ${e instanceof Error ? e.message : String(e)}`); - } + private async revokeShare(_shareId: string): Promise { + // Workspace membership is managed via the "Manage Workspaces" command. await this.sendShares(); } @@ -415,14 +369,24 @@ export class SyncSectionHandler implements SettingsSectionHandler { syncConnections: config.syncConnections, syncQueries: config.syncQueries, syncNotebooks: config.syncNotebooks, - syncPasswords: config.syncPasswords, }, auto: wsConfig.get(AUTO_SYNC_KEY, true), pullIntervalMinutes: pullInterval, + postgresConnectionId: wsConfig.get('postgresExplorer.sync.postgresConnectionId') ?? null, }, }); } + private async savePostgresConnection(postgresConnectionId: string): Promise { + if (!postgresConnectionId) { + return; + } + await vscode.workspace + .getConfiguration() + .update('postgresExplorer.sync.postgresConnectionId', postgresConnectionId, vscode.ConfigurationTarget.Global); + this.sendState(); + } + private async syncNow(direction: 'both' | 'pull' | 'push'): Promise { if (!(await requirePro(ProFeature.CloudBackup))) { this.sendState(); @@ -446,7 +410,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(); @@ -499,7 +462,6 @@ export class SyncSectionHandler implements SettingsSectionHandler { syncConnections: !!flags.syncConnections, syncQueries: !!flags.syncQueries, syncNotebooks: !!flags.syncNotebooks, - syncPasswords: !!flags.syncPasswords, }); this.sendState(); } @@ -517,4 +479,24 @@ export class SyncSectionHandler implements SettingsSectionHandler { } this.sendState(); } + + private async openNotebook(postgresConnectionId: string): Promise { + if (!postgresConnectionId) { + void vscode.window.showErrorMessage('No database connection selected.'); + return; + } + const connection = ConnectionUtils.findConnection(postgresConnectionId); + if (!connection) { + void vscode.window.showErrorMessage('Database connection not found.'); + return; + } + const treeItem = new DatabaseTreeItem( + connection.database || 'postgres', + vscode.TreeItemCollapsibleState.None, + 'database', + connection.id, + connection.database || 'postgres' + ); + await cmdNewNotebook(treeItem, this.host.extensionContext); + } } diff --git a/src/features/sync/BlobCodec.ts b/src/features/sync/BlobCodec.ts new file mode 100644 index 0000000..460ddcf --- /dev/null +++ b/src/features/sync/BlobCodec.ts @@ -0,0 +1,25 @@ +/** + * Pluggable item-blob codec. The sync engine and providers only ever talk to a + * BlobCodec, never to crypto directly — so opt-in end-to-end encryption can be + * added in a later pass by swapping the codec, with no change to the engine. + * + * Pass 1 ships {@link PlaintextCodec}: blobs are stored as-is (TLS in transit). + */ +export interface BlobCodec { + readonly id: string; + /** Local plaintext → blob for upload. */ + encode(plaintext: Buffer): Buffer; + /** Downloaded blob → local plaintext. */ + decode(blob: Buffer): Buffer; +} + +/** Identity codec — no encryption. */ +export class PlaintextCodec implements BlobCodec { + readonly id = 'plaintext'; + encode(plaintext: Buffer): Buffer { + return plaintext; + } + decode(blob: Buffer): Buffer { + return blob; + } +} diff --git a/src/features/sync/ConflictResolutionService.ts b/src/features/sync/ConflictResolutionService.ts deleted file mode 100644 index 06f487b..0000000 --- a/src/features/sync/ConflictResolutionService.ts +++ /dev/null @@ -1,171 +0,0 @@ -import * as vscode from 'vscode'; -import { SavedQueriesService } from '../savedQueries/SavedQueriesService'; -import { SyncController } from './SyncController'; -import { SyncIndex } from './SyncIndex'; -import { NotebookSyncService } from './NotebookSyncService'; -import { getOrCreateDeviceId } from './deviceId'; -import { SYNC_LAST_CONFLICTS_KEY } from './constants'; -import { isConflictCopyId } from './syncPreviewUtils'; -import type { MergeConflict, SyncItemMeta, SyncKind } from './types'; - -export interface LiveConflictView { - id: string; - kind: SyncKind; - name?: string; - remoteDeviceId?: string; - loserCopyName?: string; - source: 'lastRun' | 'copy'; -} - -/** - * Resolve sync conflicts: persisted merge conflicts and on-disk conflict copies. - */ -export class ConflictResolutionService { - constructor(private readonly context: vscode.ExtensionContext) {} - - listConflicts(): LiveConflictView[] { - const fromRun = this.context.globalState.get(SYNC_LAST_CONFLICTS_KEY, []); - const seen = new Set(); - const out: LiveConflictView[] = []; - - for (const c of fromRun) { - seen.add(c.id); - out.push({ - id: c.id, - kind: c.kind, - name: c.localName, - remoteDeviceId: c.remoteDeviceId, - loserCopyName: c.loserCopyName, - source: 'lastRun', - }); - } - - for (const item of SyncController.getInstance().listSyncedItems()) { - if (isConflictCopyId(item.id) && !seen.has(item.id)) { - out.push({ - id: item.id, - kind: item.kind, - name: item.name ?? item.id, - source: 'copy', - }); - } - } - - return out; - } - - async resolveKeepMine(conflictId: string): Promise { - await this.removeConflictCopy(conflictId); - await this.clearPersistedConflict(conflictId); - await SyncController.getInstance().runSync({ direction: 'push' }); - } - - async resolveKeepTheirs(conflictId: string): Promise { - const baseId = conflictId.replace(/-conflict-\d+$/, ''); - await this.removeConflictCopy(conflictId); - await this.removeLocalOriginal(baseId); - await this.clearPersistedConflict(conflictId); - await SyncController.getInstance().runSync({ direction: 'pull' }); - } - - async resolveKeepBoth(conflictId: string, newName: string): Promise { - const index = new SyncIndex(this.context); - const entry = index.get(conflictId); - if (entry?.kind === 'query') { - const queries = SavedQueriesService.getInstance().getQueries(); - const q = queries.find((x) => x.id === conflictId); - if (q) { - await SavedQueriesService.getInstance().saveQuery({ ...q, title: newName }); - } - } else if (entry?.kind === 'notebook') { - const nbSvc = new NotebookSyncService(this.context, index); - const deviceId = getOrCreateDeviceId(this.context); - const items = await nbSvc.collectLocalNotebooks(deviceId); - const match = items.find((i) => i.meta.id === conflictId); - if (match) { - const raw = JSON.parse(match.plaintext.toString()) as Record; - raw.name = newName; - await nbSvc.applyNotebook(raw as never, match.meta); - } - } - await this.clearPersistedConflict(conflictId); - await SyncController.getInstance().schedulePushAfterConflict(); - } - - async deleteConflictCopy(conflictId: string): Promise { - await this.removeConflictCopy(conflictId); - await this.clearPersistedConflict(conflictId); - } - - async openDiff(conflictId: string): Promise { - const baseId = conflictId.replace(/-conflict-\d+$/, ''); - const controller = SyncController.getInstance(); - const original = await controller.getItemPlaintext(baseId); - const conflict = await controller.getItemPlaintext(conflictId); - if (!original || !conflict) { - void vscode.window.showWarningMessage('Could not load both sides for diff.'); - return; - } - - const leftUri = vscode.Uri.parse(`untitled:${baseId}-mine.json`); - const rightUri = vscode.Uri.parse(`untitled:${conflictId}-theirs.json`); - await vscode.workspace.openTextDocument(leftUri.with({ scheme: 'untitled' })); - // Use temp files in mem - const leftDoc = await vscode.workspace.openTextDocument({ content: original, language: 'json' }); - const rightDoc = await vscode.workspace.openTextDocument({ content: conflict, language: 'json' }); - await vscode.commands.executeCommand( - 'vscode.diff', - leftDoc.uri, - rightDoc.uri, - `${baseId} ↔ conflict`, - ); - } - - private async removeConflictCopy(conflictId: string): Promise { - const index = new SyncIndex(this.context); - const entry = index.get(conflictId); - if (!entry) { - return; - } - if (entry.kind === 'query') { - await SavedQueriesService.getInstance().deleteQuery(conflictId); - } else if (entry.kind === 'notebook') { - await new NotebookSyncService(this.context, index).deleteNotebook({ - id: conflictId, - kind: 'notebook', - contentHash: '', - revision: 0, - updatedAt: Date.now(), - deviceId: getOrCreateDeviceId(this.context), - deleted: true, - }); - } - index.remove(conflictId); - await index.flush(); - } - - private async removeLocalOriginal(baseId: string): Promise { - const index = new SyncIndex(this.context); - const entry = index.get(baseId); - if (entry?.kind === 'query') { - await SavedQueriesService.getInstance().deleteQuery(baseId); - } else if (entry?.kind === 'notebook') { - await new NotebookSyncService(this.context, index).deleteNotebook({ - id: baseId, - kind: 'notebook', - contentHash: '', - revision: 0, - updatedAt: Date.now(), - deviceId: getOrCreateDeviceId(this.context), - deleted: true, - }); - } - } - - private async clearPersistedConflict(conflictId: string): Promise { - const baseId = conflictId.replace(/-conflict-\d+$/, ''); - const existing = this.context.globalState.get(SYNC_LAST_CONFLICTS_KEY, []); - const next = existing.filter((c) => c.id !== conflictId && c.id !== baseId); - await this.context.globalState.update(SYNC_LAST_CONFLICTS_KEY, next); - } -} diff --git a/src/features/sync/NotebookSyncService.ts b/src/features/sync/NotebookSyncService.ts index ca2b386..1c3d79a 100644 --- a/src/features/sync/NotebookSyncService.ts +++ b/src/features/sync/NotebookSyncService.ts @@ -141,7 +141,7 @@ export class NotebookSyncService { plaintext, }); seenIds.add(syncId); - seenPaths.add(filePath); + seenPaths.add(path.resolve(filePath)); }; const walk = (dir: string): void => { @@ -153,7 +153,8 @@ export class NotebookSyncService { if (entry.isDirectory()) { walk(full); } else if (entry.name.endsWith('.pgsql')) { - if (seenPaths.has(full)) { + const resolvedFull = path.resolve(full); + if (seenPaths.has(resolvedFull)) { continue; } try { @@ -161,22 +162,10 @@ export class NotebookSyncService { const parsed = JSON.parse(raw.toString()); let syncId = readNotebookSyncId(parsed); if (syncId && seenIds.has(syncId)) { - // Stale duplicate (e.g. cloud pulled the old name after a rename). - // Keep the canonical file; drop the extra copy. - const indexedPath = this.index.get(syncId)?.filePath; - if (indexedPath && indexedPath !== full && fs.existsSync(indexedPath)) { - try { - fs.unlinkSync(full); - } catch { - /* skip unreadable duplicate */ - } - continue; - } - try { - fs.unlinkSync(full); - } catch { - continue; - } + // Another file already claimed this identity this scan. Never delete + // here — silent deletion caused data loss. Skip the extra copy; if it + // is a genuine duplicate, applyNotebook reconciles it on the next pull. + continue; } if (!syncId) { syncId = crypto.randomUUID(); @@ -198,19 +187,28 @@ export class NotebookSyncService { } }; - for (const root of this.notebookRoots()) { - walk(root); - } - for (const doc of vscode.workspace.notebookDocuments) { if (doc.notebookType !== 'postgres-notebook' && doc.notebookType !== 'postgres-query') { continue; } - if (doc.isUntitled || doc.uri.scheme !== 'file' || seenPaths.has(doc.uri.fsPath)) { + const resolvedFsPath = path.resolve(doc.uri.fsPath); + if (doc.isUntitled || doc.uri.scheme !== 'file' || seenPaths.has(resolvedFsPath)) { continue; } const metadata = doc.metadata as Record; let syncId = typeof metadata.syncId === 'string' ? metadata.syncId : undefined; + + // If missing in memory, check if file on disk has it to prevent generating duplicate ID + if (!syncId && fs.existsSync(doc.uri.fsPath)) { + try { + const raw = fs.readFileSync(doc.uri.fsPath); + const parsed = JSON.parse(raw.toString()); + syncId = readNotebookSyncId(parsed); + } catch { + /* ignore */ + } + } + if (!syncId || seenIds.has(syncId)) { syncId = crypto.randomUUID(); const persisted = await this.writeSyncIdToDocument(doc, syncId); @@ -234,6 +232,10 @@ export class NotebookSyncService { ); } + for (const root of this.notebookRoots()) { + walk(root); + } + return items; } diff --git a/src/features/sync/SharingService.ts b/src/features/sync/SharingService.ts deleted file mode 100644 index 3b4c09e..0000000 --- a/src/features/sync/SharingService.ts +++ /dev/null @@ -1,293 +0,0 @@ -import * as crypto from 'crypto'; -import * as http from 'http'; -import * as https from 'https'; -import { URL } from 'url'; -import * as vscode from 'vscode'; -import { AccountService } from './AccountService'; -import { VaultService } from './VaultService'; -import { SyncController } from './SyncController'; -import { SavedQueriesService } from '../savedQueries/SavedQueriesService'; -import { NotebookSyncService } from './NotebookSyncService'; -import { SyncIndex } from './SyncIndex'; -import { getOrCreateDeviceId } from './deviceId'; -import { DEFAULT_SYNC_API_ENDPOINT } from './constants'; -import { - decryptWithShareKey, - encryptWithShareKey, - generateShareKey, - openSealed, - sealTo, -} from './shareCrypto'; -import { materializeShared, scrubForShare, type SharedItemPayload } from './shareScrub'; -import type { SyncKind } from './types'; - -export interface IncomingShare { - shareId: string; - ownerEmail: string; - kind: SyncKind; - name?: string; - shareBlob: string; - wrappedKey: string; - createdAt: string; -} - -export type ImportMode = 'merge' | 'copy'; - -/** - * Team sharing over the NexQL Cloud backend. Owners seal selected items to a - * grantee's X25519 public key; grantees import them, merging with or copying - * into their own library — never receiving the owner's connection or secrets. - */ -export class SharingService { - constructor(private readonly context: vscode.ExtensionContext) {} - - private endpoint(): string { - const configured = vscode.workspace - .getConfiguration() - .get('postgresExplorer.sync.apiEndpoint'); - return (configured?.trim() || DEFAULT_SYNC_API_ENDPOINT).replace(/\/$/, ''); - } - - /** Publish this vault's public key so others can share to this account. */ - async registerPublicKey(): Promise { - const publicKey = await VaultService.getInstance().getIdentityPublicKey(); - await this.request('POST', '/sync/keys', { public_key: publicKey }); - } - - private async fetchPublicKey(email: string): Promise { - const res = await this.request<{ public_key?: string }>( - 'GET', - `/sync/keys?email=${encodeURIComponent(email)}`, - ); - if (!res?.public_key) { - throw new Error(`No NexQL identity found for ${email}. Ask them to enable sync first.`); - } - return res.public_key; - } - - /** Seal the given local items to a grantee and create server-side shares. */ - async shareItems(granteeEmail: string, itemIds: string[]): Promise { - const controller = SyncController.getInstance(); - const recipientPublicKey = await this.fetchPublicKey(granteeEmail); - - // One share key per batch, sealed once to the grantee and reused per item. - const shareKey = generateShareKey(); - const wrappedKey = sealTo(recipientPublicKey, Buffer.from(shareKey, 'base64')); - - const items: Array<{ share_id: string; kind: SyncKind; name: string; share_blob: string; wrapped_key: string }> = []; - for (const id of itemIds) { - const local = await controller.getShareableItem(id); - if (!local) { - continue; - } - const scrubbed = scrubForShare(local.kind, local.raw); - const shareBlob = encryptWithShareKey(shareKey, Buffer.from(JSON.stringify(scrubbed))); - items.push({ - share_id: crypto.randomUUID(), - kind: local.kind, - name: local.name, - share_blob: shareBlob, - wrapped_key: wrappedKey, - }); - } - - if (items.length === 0) { - return 0; - } - await this.request('POST', '/sync/shares', { grantee_email: granteeEmail, items }); - return items.length; - } - - async listIncomingShares(): Promise { - const rows = await this.request>('GET', '/sync/shares'); - return (rows ?? []).map((r) => ({ - shareId: r.share_id, - ownerEmail: r.owner_email, - kind: r.kind, - name: r.name, - shareBlob: r.share_blob, - wrappedKey: r.wrapped_key, - createdAt: r.created_at, - })); - } - - async listOutgoingShares(): Promise> { - const rows = await this.request>('GET', '/sync/shares?direction=outgoing'); - return (rows ?? []).map((r) => ({ - shareId: r.share_id, - granteeEmail: r.grantee_email, - kind: r.item_kind, - name: r.item_name, - createdAt: r.created_at, - revoked: r.revoked, - })); - } - - async revokeShare(shareId: string): Promise { - await this.request('DELETE', `/sync/shares/${encodeURIComponent(shareId)}`); - } - - /** Decrypt a single incoming share into its scrubbed payload. */ - private async decryptShare(share: IncomingShare): Promise { - const { privateKey } = await VaultService.getInstance().getIdentityKeyPair(); - const shareKey = openSealed(privateKey, share.wrappedKey).toString('base64'); - const plain = decryptWithShareKey(shareKey, share.shareBlob); - return JSON.parse(plain.toString()) as SharedItemPayload; - } - - /** - * Import incoming shares into the grantee's library. - * - `copy`: each item gets a fresh id (detached duplicate). - * - `merge`: reuse a stable id derived from the share so re-imports update - * in place rather than piling up duplicates. - * Imported items carry no connection binding; the grantee may attach one. - */ - async importShares( - shares: IncomingShare[], - mode: ImportMode, - connectionId: string | undefined, - ): Promise { - let imported = 0; - const now = Date.now(); - const index = new SyncIndex(this.context); - const nbSvc = new NotebookSyncService(this.context, index); - const deviceId = getOrCreateDeviceId(this.context); - - for (const share of shares) { - let scrubbed: SharedItemPayload; - try { - scrubbed = await this.decryptShare(share); - } catch { - void vscode.window.showWarningMessage(`Could not decrypt shared item from ${share.ownerEmail}.`); - continue; - } - - const newId = mode === 'merge' - ? `shared-${crypto.createHash('sha256').update(share.shareId).digest('hex').slice(0, 24)}` - : crypto.randomUUID(); - const materialized = materializeShared(scrubbed, newId, connectionId, now); - - if (scrubbed.kind === 'query') { - await SavedQueriesService.getInstance().saveQuery(materialized as never); - imported += 1; - } else if (scrubbed.kind === 'notebook') { - await nbSvc.applyNotebook(materialized as never, { - id: newId, - kind: 'notebook', - contentHash: '', - revision: 1, - updatedAt: now, - deviceId, - deleted: false, - }); - imported += 1; - } - } - await index.flush(); - return imported; - } - - // ── HTTP ────────────────────────────────────────────────────────────────── - - private async request( - method: 'GET' | 'POST' | 'DELETE', - path: string, - body?: Record, - ): Promise { - const account = AccountService.getInstance(this.context); - let token = await account.getAccessToken(); - if (!token) { - throw new Error('Not signed in to NexQL Cloud. Set up Cloud sync first.'); - } - let res = await this.send(method, path, token, body); - if (res.status === 401) { - const refreshed = await account.refreshAccessToken(); - if (refreshed) { - token = refreshed; - res = await this.send(method, path, token, body); - } - } - if (res.status === 401 || res.status === 403) { - throw new Error('Sharing requires an active Teams (Singularity) subscription.'); - } - if (res.status >= 400) { - throw new Error(res.error || `Request failed (${res.status})`); - } - return res.data; - } - - private send( - method: string, - path: string, - token: string, - body?: Record, - ): Promise<{ status: number; data?: T; error?: string }> { - const url = new URL(`${this.endpoint()}${path}`); - const payload = body ? JSON.stringify(body) : undefined; - const lib = url.protocol === 'http:' ? http : https; - - return new Promise((resolve, reject) => { - const req = lib.request( - { - protocol: url.protocol, - hostname: url.hostname, - port: url.port || undefined, - path: url.pathname + url.search, - method, - headers: { - Authorization: `Bearer ${token}`, - ...(payload - ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) } - : {}), - }, - timeout: 20000, - }, - (res) => { - let data = ''; - res.on('data', (c) => (data += c)); - res.on('end', () => { - const status = res.statusCode ?? 0; - if (status === 204 || !data) { - resolve({ status }); - return; - } - try { - const parsed = JSON.parse(data); - resolve({ status, data: parsed as T, error: parsed?.error }); - } catch { - resolve({ status, error: 'Invalid JSON response' }); - } - }); - }, - ); - req.on('timeout', () => req.destroy(new Error('timeout'))); - req.on('error', reject); - if (payload) { - req.write(payload); - } - req.end(); - }); - } -} diff --git a/src/features/sync/SyncController.ts b/src/features/sync/SyncController.ts index 7d403ff..1aaa1a9 100644 --- a/src/features/sync/SyncController.ts +++ b/src/features/sync/SyncController.ts @@ -1,186 +1,128 @@ +import * as fs from 'fs'; import * as vscode from 'vscode'; +import { contentHash } from './envelope'; +import { SyncIndex } from './SyncIndex'; +import { SyncMutex } from './SyncMutex'; +import { PlaintextCodec, type BlobCodec } from './BlobCodec'; +import { decideIncoming } from './SyncEngineV2'; +import { ConnectionSyncService } from './ConnectionSyncService'; +import { NotebookSyncService } from './NotebookSyncService'; +import { CloudSyncProvider } from './providers/CloudSyncProvider'; +import { PostgresSyncProvider } from './providers/PostgresSyncProvider'; +import { SyncActivityLog, bindSyncActivityLog, recordSyncActivity } from './SyncActivityLog'; +import { getOrCreateDeviceId } from './deviceId'; import { SavedQueriesService } from '../savedQueries/SavedQueriesService'; -import { TelemetryService } from '../../services/TelemetryService'; import { + ProFeature, isProFeatureEnabled, isSyncProviderAllowed, - ProFeature, - requirePro, - syncProviderMinTier, - TIER_DISPLAY, } from '../../services/featureGates'; -import { AccountService } from './AccountService'; -import { VaultService } from './VaultService'; -import { buildSyncChangeSummary, formatCountsLine } from './syncChangeStats'; -import { attachEncryptedBlobs, mergeSyncState, tombstoneMeta } from './SyncEngine'; -import { getOrCreateDeviceId, getDeviceName } from './deviceId'; -import { NotebookSyncService } from './NotebookSyncService'; -import { ConnectionSyncService } from './ConnectionSyncService'; -import { SyncIndex } from './SyncIndex'; -import { contentHash } from './envelope'; +import type { SyncStatusBar } from '../../activation/statusBar'; import { - SYNC_BACKOFF_INITIAL_MS, - SYNC_BACKOFF_MAX_MS, - SYNC_BASE_MANIFEST_KEY, SYNC_CONFIG_KEY, - SYNC_DEBOUNCE_MS, - SYNC_LAST_CONFLICTS_KEY, - SYNC_LAST_ERROR_KEY, + SYNC_CURSOR_KEY, SYNC_LAST_SYNC_AT_KEY, + SYNC_LAST_ERROR_KEY, + SYNC_DEBOUNCE_MS, SYNC_PERIODIC_MS, - SYNC_PREVIEW_CACHE_KEY, } from './constants'; import type { + CloudQuotaView, + InboundEntry, + SyncChangeSummary, SyncConfig, + SyncDelta, + SyncDeviceView, + SyncDirectionSummary, SyncItemMeta, - SyncProvider, + SyncOp, + RemoteItemMeta, + SyncPreviewItem, + SyncPreviewResult, SyncProviderId, - SyncRunResult, + SyncProviderV2, SyncRunOptions, - SyncPreviewResult, + SyncRunResult, SyncStatus, - SyncActivityView, SyncedItemView, - CloudQuotaView, - SyncDeviceView, - InboundEntry, } from './types'; -import { GistSyncProvider, OversizedItemError } from './providers/GistSyncProvider'; -import { OneDriveSyncProvider } from './providers/OneDriveSyncProvider'; -import { GoogleDriveSyncProvider } from './providers/GoogleDriveSyncProvider'; -import { CloudSyncProvider } from './providers/CloudSyncProvider'; -import { PostgresSyncProvider } from './providers/PostgresSyncProvider'; -import { bindSyncActivityLog, recordSyncActivity, SyncActivityLog } from './SyncActivityLog'; -import { readNotebookSyncId } from './notebookSyncId'; -import { buildPreviewFromMerge, mergeBaseManifestPartial } from './syncPreviewUtils'; -import type { SyncStatusBar } from '../../activation/statusBar'; -function metaKey(m: SyncItemMeta): string { - return `${m.kind}:${m.id}`; +type LocalItem = { meta: SyncItemMeta; plaintext: Buffer }; + +const DEFAULT_CONFIG: SyncConfig = { + syncConnections: true, + syncQueries: true, + syncNotebooks: true, + paused: false, +}; + +function emptyDirection(): SyncDirectionSummary { + const z = () => ({ created: 0, updated: 0, deleted: 0 }); + return { connections: z(), queries: z(), notebooks: z() }; } +function emptySummary(): SyncChangeSummary { + return { pushed: emptyDirection(), pulled: emptyDirection() }; +} + +/** + * Git-like sync orchestrator. The server is the source of truth; this device + * tracks a single cursor per space and reconciles via delta pull + atomic + * compare-and-swap push. Status only reaches `synced` after a full run commits. + */ export class SyncController implements vscode.Disposable { - private static instance: SyncController; - private debounceTimer: ReturnType | undefined; - private periodicTimer: ReturnType | undefined; - private backoffMs = SYNC_BACKOFF_INITIAL_MS; + private static instance: SyncController | undefined; + + private readonly mutex = new SyncMutex(); + private readonly codec: BlobCodec = new PlaintextCodec(); private status: SyncStatus = 'not_configured'; private conflictCount = 0; - private statusBar: SyncStatusBar | undefined; - private readonly disposables: vscode.Disposable[] = []; + private statusBar?: SyncStatusBar; + private provider?: SyncProviderV2; + private debounceTimer?: NodeJS.Timeout; + private periodicTimer?: NodeJS.Timeout; + private disposables: vscode.Disposable[] = []; private constructor( private readonly context: vscode.ExtensionContext, - private readonly outputChannel: vscode.OutputChannel, + private readonly output: vscode.OutputChannel, ) {} - static getInstance(context?: vscode.ExtensionContext, outputChannel?: vscode.OutputChannel): SyncController { + static getInstance(context?: vscode.ExtensionContext, output?: vscode.OutputChannel): SyncController { if (!SyncController.instance) { - if (!context || !outputChannel) { + if (!context || !output) { throw new Error('SyncController not initialized'); } - SyncController.instance = new SyncController(context, outputChannel); + SyncController.instance = new SyncController(context, output); } return SyncController.instance; } static resetInstanceForTests(): void { - SyncController.instance = undefined as unknown as SyncController; + SyncController.instance = undefined; } initialize(statusBar?: SyncStatusBar): void { this.statusBar = statusBar; bindSyncActivityLog(this.context); - VaultService.getInstance(this.context); - AccountService.getInstance(this.context); - const config = this.getConfig(); this.status = config.providerId ? (config.paused ? 'paused' : 'idle') : 'not_configured'; this.updateStatusBar(); - this.seedConnectionSnapshot(); - - if (config.providerId && !config.paused) { - void this.bootstrapVaultAndSync(); - } this.disposables.push( vscode.workspace.onDidChangeConfiguration((e) => { if (e.affectsConfiguration('postgresExplorer.connections')) { - if (this.recordConnectionConfigChanges()) { - this.scheduleInstantSync(); - } + recordSyncActivity({ kind: 'connection', action: 'update', itemId: 'connections', name: 'Connections' }); + this.schedulePush(); } }), - vscode.workspace.onDidSaveNotebookDocument((doc) => { - this.recordOpenNotebookActivity(doc, 'update'); - this.schedulePush(); - }), - vscode.workspace.onDidChangeNotebookDocument(() => this.schedulePush()), + vscode.workspace.onDidSaveNotebookDocument(() => this.scheduleInstantSync()), ); - try { - const notebookPattern = new vscode.RelativePattern(this.context.globalStorageUri, '**/*.pgsql'); - const notebookWatcher = vscode.workspace.createFileSystemWatcher(notebookPattern); - notebookWatcher.onDidCreate((uri) => { - void this.recordNotebookFileActivity(uri, 'create').then(() => { - this.scheduleInstantSync(); - }); - }); - notebookWatcher.onDidChange((uri) => { - void this.recordNotebookFileActivity(uri, 'update'); - this.schedulePush(); - }); - notebookWatcher.onDidDelete((uri) => { - this.recordNotebookFileDelete(uri); - this.scheduleInstantSync(); - }); - this.disposables.push(notebookWatcher); - } catch { - /* watcher unavailable — manual sync still works */ - } - - if ( - this.isAutoSyncEnabled() && - config.providerId && - !config.paused && - isProFeatureEnabled(ProFeature.CloudSync) - ) { - const intervalMin = vscode.workspace - .getConfiguration() - .get('postgresExplorer.sync.pullIntervalMinutes', 5); - const intervalMs = Math.max(1, intervalMin) * 60 * 1000; - this.periodicTimer = setInterval(() => void this.runSync(), intervalMs); - } - } - - /** Restore cached vault key after restart, then run an initial pull/push. */ - private async bootstrapVaultAndSync(): Promise { - const config = this.getConfig(); - const vault = VaultService.getInstance(); - const account = AccountService.getInstance(); - - if (!vault.isUnlocked()) { - const loaded = await vault.tryLoadCachedKey(); - if (!loaded && config.providerId === 'cloud') { - const token = await account.getAccessToken() ?? await account.refreshAccessToken(); - if (token) { - const loadedAfterToken = await vault.tryLoadCachedKey(); - if (loadedAfterToken) { - this.status = config.paused ? 'paused' : 'idle'; - this.updateStatusBar(); - } - } - } - if (!vault.isUnlocked()) { - this.status = 'locked'; - this.updateStatusBar(); - return; - } - } - if (!this.isAutoSyncEnabled() || !isProFeatureEnabled(ProFeature.CloudSync)) { - return; + if (config.providerId && !config.paused && this.isAutoSyncEnabled()) { + this.startPeriodicPull(); + void this.runSync().catch(() => undefined); } - setTimeout(() => void this.runSync(), 2000); } setStatusBar(statusBar: SyncStatusBar): void { @@ -188,79 +130,110 @@ export class SyncController implements vscode.Disposable { this.updateStatusBar(); } - getStatus(): SyncStatus { - return this.status; - } - - getConflictCount(): number { - return this.conflictCount; - } + // ── Config / cursor ───────────────────────────────────────────────────────── getConfig(): SyncConfig { - return this.context.globalState.get(SYNC_CONFIG_KEY, { - syncConnections: true, - syncQueries: true, - syncNotebooks: true, - syncPasswords: false, - paused: false, - }); + return { ...DEFAULT_CONFIG, ...this.context.globalState.get(SYNC_CONFIG_KEY, {} as SyncConfig) }; } async saveConfig(config: SyncConfig): Promise { await this.context.globalState.update(SYNC_CONFIG_KEY, config); - this.status = config.paused ? 'paused' : 'idle'; + this.provider = undefined; + this.status = config.providerId ? (config.paused ? 'paused' : 'idle') : 'not_configured'; + if (config.providerId && !config.paused && this.isAutoSyncEnabled()) { + this.startPeriodicPull(); + } else { + this.stopPeriodicPull(); + } this.updateStatusBar(); } - private isAutoSyncEnabled(): boolean { - return vscode.workspace.getConfiguration().get('postgresExplorer.sync.auto', true); + private cursorKey(config: SyncConfig): string { + return `${config.providerId}:${config.spaceId ?? 'personal'}`; } - private getBaseManifest(): SyncItemMeta[] { - return this.context.globalState.get(SYNC_BASE_MANIFEST_KEY, []); + private getCursor(config: SyncConfig): number { + const all = this.context.globalState.get>(SYNC_CURSOR_KEY, {}); + return all[this.cursorKey(config)] ?? 0; } - private async setBaseManifest(manifest: SyncItemMeta[]): Promise { - await this.context.globalState.update(SYNC_BASE_MANIFEST_KEY, manifest); + private async setCursor(config: SyncConfig, cursor: number): Promise { + const all = { ...this.context.globalState.get>(SYNC_CURSOR_KEY, {}) }; + all[this.cursorKey(config)] = cursor; + await this.context.globalState.update(SYNC_CURSOR_KEY, all); } - createProvider(providerId: SyncProviderId): SyncProvider { + createProvider(providerId: SyncProviderId, spaceId?: string): SyncProviderV2 { switch (providerId) { - case 'gist': - return new GistSyncProvider(this.context); - case 'onedrive': - return new OneDriveSyncProvider(this.context); - case 'gdrive': - return new GoogleDriveSyncProvider(this.context); case 'cloud': - return new CloudSyncProvider(this.context); + return new CloudSyncProvider(this.context, spaceId); case 'postgres': return new PostgresSyncProvider(this.context); default: - throw new Error(`Unknown provider: ${providerId}`); + throw new Error(`Unknown sync provider: ${providerId}`); + } + } + + private getProvider(config: SyncConfig): SyncProviderV2 { + if (!this.provider) { + this.provider = this.createProvider(config.providerId!, config.spaceId); } + return this.provider; + } + + // ── Status ────────────────────────────────────────────────────────────────── + + getStatus(): SyncStatus { + return this.status; + } + + getConflictCount(): number { + return this.conflictCount; + } + + getLastSyncAt(): number | undefined { + return this.context.globalState.get(SYNC_LAST_SYNC_AT_KEY); + } + + getLastError(): string | undefined { + return this.context.globalState.get(SYNC_LAST_ERROR_KEY); + } + + private setStatus(status: SyncStatus): void { + this.status = status; + this.updateStatusBar(); + } + + private updateStatusBar(): void { + this.statusBar?.updateSyncStatus(this.status, this.conflictCount, !!this.getConfig().providerId, { + lastSyncAt: this.getLastSyncAt(), + pendingCount: this.listPendingActivities().length, + }); + } + + private isAutoSyncEnabled(): boolean { + return vscode.workspace.getConfiguration().get('postgresExplorer.sync.auto', true); } + // ── Scheduling ──────────────────────────────────────────────────────────────── + schedulePush(): void { const config = this.getConfig(); - if ( - !config.providerId || - config.paused || - !this.isAutoSyncEnabled() || - !isProFeatureEnabled(ProFeature.CloudSync) - ) { + if (!config.providerId || config.paused || !this.isAutoSyncEnabled()) { + return; + } + if (!isProFeatureEnabled(ProFeature.CloudSync)) { return; } if (this.debounceTimer) { clearTimeout(this.debounceTimer); } - this.debounceTimer = setTimeout(() => void this.runSync(), SYNC_DEBOUNCE_MS); + this.debounceTimer = setTimeout(() => void this.runSync().catch(() => undefined), SYNC_DEBOUNCE_MS); } - /** Immediate sync for structural changes (create / rename / delete). */ scheduleInstantSync(): void { const config = this.getConfig(); - if (!config.providerId || config.paused) { + if (!config.providerId || config.paused || !this.isAutoSyncEnabled()) { return; } if (!isProFeatureEnabled(ProFeature.CloudBackup)) { @@ -268,1002 +241,671 @@ export class SyncController implements vscode.Disposable { } if (this.debounceTimer) { clearTimeout(this.debounceTimer); - this.debounceTimer = undefined; } - void this.runSync(); + this.debounceTimer = setTimeout(() => void this.runSync().catch(() => undefined), 750); + } + + private startPeriodicPull(): void { + this.stopPeriodicPull(); + this.periodicTimer = setInterval(() => { + if (isProFeatureEnabled(ProFeature.CloudSync)) { + void this.runSync({ direction: 'pull' }).catch(() => undefined); + } + }, SYNC_PERIODIC_MS); + } + + private stopPeriodicPull(): void { + if (this.periodicTimer) { + clearInterval(this.periodicTimer); + this.periodicTimer = undefined; + } } + // ── Sync run ────────────────────────────────────────────────────────────────── + async runSync(options: SyncRunOptions = {}): Promise { const config = this.getConfig(); if (!config.providerId || config.paused) { 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.`, - 'View Plans', - ).then((c) => { - if (c === 'View Plans') { - void vscode.env.openExternal(vscode.Uri.parse('https://nexql.astrx.dev/#pricing')); - } - }); + this.setStatus('error'); return undefined; } + if (options.dryRun) { + return this.preview(config, options.transientExcludedIds); + } - const vault = VaultService.getInstance(); - if (!vault.isUnlocked()) { - const loaded = await vault.tryLoadCachedKey(); - if (!loaded) { - this.status = 'locked'; - this.updateStatusBar(); - return undefined; - } + const release = await this.mutex.acquire(); + const started = Date.now(); + try { + this.setStatus('syncing'); + const result = await this.runLocked(config, options); + this.conflictCount = result.conflicts; + await this.context.globalState.update(SYNC_LAST_SYNC_AT_KEY, Date.now()); + await this.context.globalState.update(SYNC_LAST_ERROR_KEY, undefined); + this.setStatus(result.conflicts > 0 ? 'conflict' : 'synced'); + result.durationMs = Date.now() - started; + return result; + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + await this.context.globalState.update(SYNC_LAST_ERROR_KEY, message); + this.output.appendLine(`[sync] run failed: ${message}`); + this.setStatus('error'); + return undefined; + } finally { + release(); } + } - this.status = 'syncing'; - this.updateStatusBar(); - const start = Date.now(); - const deviceId = getOrCreateDeviceId(this.context); - const provider = this.createProvider(config.providerId); - const index = new SyncIndex(this.context); + async pullOnly(): Promise { + return this.runSync({ direction: 'pull' }) as Promise; + } - try { - if (!isProFeatureEnabled(ProFeature.CloudSync)) { - const bound = await this.ensureDeviceBinding(provider, deviceId); - if (!bound) { - this.status = 'error'; - this.updateStatusBar(); - return undefined; + async pushOnly(): Promise { + return this.runSync({ direction: 'push' }) as Promise; + } + + async previewSync(transientExcludedIds?: string[]): Promise { + return this.runSync({ dryRun: true, transientExcludedIds }) as Promise; + } + + /** The atomic heart: pull → apply → push (with one pull/re-push on conflict). */ + private async runLocked(config: SyncConfig, options: SyncRunOptions): Promise { + const provider = this.getProvider(config); + const index = new SyncIndex(this.context); + const excluded = new Set([...(config.excludedIds ?? []), ...(options.transientExcludedIds ?? [])]); + const direction = options.direction ?? 'both'; + let cursor = this.getCursor(config); + let pulled = 0; + let pushed = 0; + let conflicts = 0; + + // Phase 1 — pull + apply (atomic per item; cursor only advances after apply). + if (direction !== 'push') { + const delta = await provider.pullDelta(cursor); + pulled = await this.applyDelta(delta, config, index, excluded); + cursor = delta.cursor; + await this.setCursor(config, cursor); + } + + // Phase 2 — push dirty + deletions. + if (direction !== 'pull') { + const ops = await this.buildOps(config, index, excluded); + if (ops.length) { + const result = await provider.pushBatch(ops); + this.recordAccepted(result.accepted, ops, index); + pushed = result.accepted.length; + cursor = result.cursor; + await this.setCursor(config, cursor); + + if (result.rejected.length) { + // Concurrent writer — pull their changes, then re-push once (git-style). + const delta2 = await provider.pullDelta(cursor); + pulled += await this.applyDelta(delta2, config, index, excluded); + cursor = delta2.cursor; + await this.setCursor(config, cursor); + + const ops2 = await this.buildOps(config, index, excluded); + if (ops2.length) { + const retry = await provider.pushBatch(ops2); + this.recordAccepted(retry.accepted, ops2, index); + pushed += retry.accepted.length; + cursor = retry.cursor; + await this.setCursor(config, cursor); + conflicts = retry.rejected.length; + } } } + } - const baseManifest = this.getBaseManifest(); - const excluded = new Set([ - ...(config.excludedIds ?? []), - ...(options.transientExcludedIds ?? []), - ]); - const localItems = (await this.collectLocalItems(config, deviceId, index)) - .filter((i) => !excluded.has(i.meta.id)); - this.appendLocalTombstones(baseManifest, localItems, config, deviceId, index, excluded); - - const remoteSnapshot = await provider.pull(); - const remoteItems = remoteSnapshot.manifest - .filter((meta) => !excluded.has(meta.id)) - .map((meta) => ({ - meta, - getBlob: () => remoteSnapshot.getBlob(meta.id), - })); - - const merge = await mergeSyncState( - baseManifest, - localItems, - remoteItems, - deviceId, - (blob) => vault.decrypt(blob), - ); + await index.flush(); + this.acknowledgeActivities(index); - let encryptedPush = attachEncryptedBlobs(merge.toPush, localItems, (p) => vault.encrypt(p)); - let newBaseManifest = merge.newBaseManifest; - let toApply = merge.toApply; + return { + pushed, + pulled, + conflicts, + skipped: 0, + durationMs: 0, + provider: config.providerId!, + summary: emptySummary(), + }; + } - if (excluded.size > 0) { - encryptedPush = encryptedPush.filter((i) => !excluded.has(i.meta.id)); - toApply = merge.toApply.filter((i) => !excluded.has(i.meta.id)); - } + private async preview(config: SyncConfig, transientExcludedIds?: string[]): Promise { + const provider = this.getProvider(config); + const index = new SyncIndex(this.context); + const excluded = new Set([...(config.excludedIds ?? []), ...(transientExcludedIds ?? [])]); + const delta = await provider.pullDelta(this.getCursor(config)); + const localItems = await this.collectLocalItems(config, excluded, index); + const localById = new Map(localItems.map((i) => [i.meta.id, i])); + + const incoming: SyncPreviewItem[] = [ + ...delta.upserts + .filter((u) => !excluded.has(u.meta.id)) + .map((u) => ({ + id: u.meta.id, + kind: u.meta.kind, + changeType: (localById.has(u.meta.id) ? 'update' : 'create') as 'update' | 'create', + deviceId: u.meta.deviceId, + })), + ...delta.deletes + .filter((id) => !excluded.has(id)) + .map((id) => ({ id, kind: (index.get(id)?.kind ?? 'query'), changeType: 'delete' as const })), + ]; + + const ops = await this.buildOps(config, index, excluded); + const outgoing: SyncPreviewItem[] = ops.map((op) => ({ + id: op.itemId, + kind: op.kind, + name: index.get(op.itemId)?.name, + changeType: op.op === 'delete' ? 'delete' : (index.baseVersion(op.itemId) ? 'update' : 'create'), + })); - const previewLists = buildPreviewFromMerge( - baseManifest, - localItems, - encryptedPush, - toApply, - merge.conflicts, - (id, kind) => this.resolveItemName(id, kind, index), - ); + return { + pushed: 0, + pulled: 0, + conflicts: 0, + skipped: 0, + durationMs: 0, + provider: config.providerId!, + summary: emptySummary(), + outgoing, + incoming, + conflictItems: [], + }; + } - if (dryRun) { - const summary = buildSyncChangeSummary(baseManifest, localItems, encryptedPush, toApply); - const preview: SyncPreviewResult = { - pushed: encryptedPush.length, - pulled: toApply.length, - conflicts: merge.conflicts.length, - skipped: merge.skipped.length, - durationMs: Date.now() - start, - provider: config.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.updateStatusBar(); - return preview; - } + // ── Collect / build ops ──────────────────────────────────────────────────────── - let skipped = merge.skipped.length; - const doPush = direction === 'both' || direction === 'push'; - const doPull = direction === 'both' || direction === 'pull'; + private async collectLocalItems( + config: SyncConfig, + excluded: ReadonlySet, + index: SyncIndex, + ): Promise { + const deviceId = getOrCreateDeviceId(this.context); + const items: LocalItem[] = []; - if (doPush) { - const pushOptions = { manifest: newBaseManifest }; - try { - await provider.push(encryptedPush, pushOptions); - } catch (e) { - if (e instanceof OversizedItemError) { - skipped += e.itemIds.length; - for (const id of e.itemIds) { - vscode.window.showWarningMessage(`Sync skipped oversized notebook: ${id}`); - } - const oversized = new Set(e.itemIds); - encryptedPush = encryptedPush.filter((i) => !oversized.has(i.meta.id)); - const baseById = new Map(baseManifest.map((m) => [m.id, m])); - newBaseManifest = newBaseManifest - .map((m) => (oversized.has(m.id) ? baseById.get(m.id) : m)) - .filter((m): m is SyncItemMeta => m !== undefined); - await provider.push(encryptedPush, { manifest: newBaseManifest }); - } else { - throw e; - } - } + if (config.syncConnections) { + items.push(...new ConnectionSyncService(this.context, index).collectLocalConnections(deviceId)); + } + if (config.syncQueries) { + for (const q of SavedQueriesService.getInstance().getQueries()) { + const plaintext = Buffer.from(JSON.stringify(q)); + const hash = contentHash(plaintext); + const { updatedAt } = index.observe(q.id, 'query', hash, { name: q.title }); + items.push({ + meta: { id: q.id, kind: 'query', contentHash: hash, revision: 0, updatedAt, deviceId, deleted: false }, + plaintext, + }); + } + } + if (config.syncNotebooks) { + items.push(...(await new NotebookSyncService(this.context, index).collectLocalNotebooks(deviceId))); + } + return items.filter((i) => !excluded.has(i.meta.id)); + } + + /** Upserts for dirty local items + deletes for items removed since last sync. */ + private async buildOps(config: SyncConfig, index: SyncIndex, excluded: ReadonlySet): Promise { + const localItems = await this.collectLocalItems(config, excluded, index); + const presentIds = new Set(localItems.map((i) => i.meta.id)); + const ops: SyncOp[] = []; + + for (const { meta, plaintext } of localItems) { + if (index.isDirty(meta.id, meta.contentHash)) { + ops.push({ + op: 'upsert', + itemId: meta.id, + kind: meta.kind, + baseVersion: index.baseVersion(meta.id), + contentHash: meta.contentHash, + blob: this.codec.encode(plaintext), + }); } + } - if (doPull) { - await this.applyRemoteItems(toApply, config, index); - this.logInboundApplied(toApply, index); + // Deletions: synced before, gone locally now. + const kindEnabled = (k: string) => + (k === 'connection' && config.syncConnections) || + (k === 'query' && config.syncQueries) || + (k === 'notebook' && config.syncNotebooks); + for (const id of index.syncedIds()) { + const entry = index.get(id); + if (!entry || excluded.has(id) || presentIds.has(id) || !kindEnabled(entry.kind)) { + continue; } + ops.push({ op: 'delete', itemId: id, kind: entry.kind, baseVersion: index.baseVersion(id) }); + } + return ops; + } - const syncedKeys = new Set(); - if (doPush) { - for (const item of encryptedPush) { - syncedKeys.add(metaKey(item.meta)); - } + private recordAccepted( + accepted: Array<{ itemId: string; version: number }>, + ops: SyncOp[], + index: SyncIndex, + ): void { + const opById = new Map(ops.map((o) => [o.itemId, o])); + for (const { itemId, version } of accepted) { + const op = opById.get(itemId); + if (!op) { + continue; } - if (doPull) { - for (const item of toApply) { - syncedKeys.add(metaKey(item.meta)); - } + if (op.op === 'delete') { + index.remove(itemId); + } else { + index.markSynced(itemId, { kind: op.kind, contentHash: op.contentHash!, version }); } + } + } - const updatedBase = mergeBaseManifestPartial(baseManifest, newBaseManifest, syncedKeys); - await this.setBaseManifest(updatedBase); - index.markSynced(updatedBase); - this.purgeStaleNotebookIndex(index, updatedBase); - await index.flush(); - - SyncActivityLog.getInstance(this.context).acknowledge(syncedKeys); - - await this.context.globalState.update(SYNC_LAST_CONFLICTS_KEY, merge.conflicts); - this.conflictCount = merge.conflicts.length + this.countConflictCopies(); - this.status = this.conflictCount > 0 ? 'conflict' : 'synced'; - this.backoffMs = SYNC_BACKOFF_INITIAL_MS; + // ── Apply incoming delta ─────────────────────────────────────────────────────── - await this.context.globalState.update(SYNC_LAST_SYNC_AT_KEY, Date.now()); - await this.context.globalState.update(SYNC_LAST_ERROR_KEY, undefined); + private async applyDelta( + delta: SyncDelta, + config: SyncConfig, + index: SyncIndex, + excluded: ReadonlySet, + ): Promise { + const connSvc = new ConnectionSyncService(this.context, index); + const nbSvc = new NotebookSyncService(this.context, index); + const sqSvc = SavedQueriesService.getInstance(); + const localItems = await this.collectLocalItems(config, excluded, index); + const localById = new Map(localItems.map((i) => [i.meta.id, i])); + let applied = 0; - if (merge.conflicts.length > 0) { - const choice = await vscode.window.showWarningMessage( - `${merge.conflicts.length} sync conflict(s) — review and resolve.`, - 'Resolve…', - ); - if (choice === 'Resolve…') { - void vscode.commands.executeCommand('postgres-explorer.settingsHub', { section: 'sync', tab: 'conflicts' }); + // Permanent deletes — never resurrected. + for (const id of delta.deletes) { + if (excluded.has(id)) { + continue; + } + const kind = index.get(id)?.kind ?? localById.get(id)?.meta.kind; + const metaStub: SyncItemMeta = { id, kind: kind ?? 'query', contentHash: '', revision: 0, updatedAt: 0, deviceId: '', deleted: true }; + try { + if (kind === 'connection' && config.syncConnections) { + await connSvc.removeConnection(metaStub); + } else if (kind === 'notebook' && config.syncNotebooks) { + await nbSvc.deleteNotebook(metaStub); + } else if (kind === 'query' && config.syncQueries) { + await sqSvc.deleteQuery(id); } + } catch (e) { + this.output.appendLine(`[sync] delete ${id} failed: ${e instanceof Error ? e.message : String(e)}`); } + index.remove(id); + applied += 1; + } - const summary = buildSyncChangeSummary( - baseManifest, - localItems, - doPush ? encryptedPush : [], - doPull ? toApply : [], - ); - - const result: SyncRunResult = { - pushed: doPush ? encryptedPush.length : 0, - pulled: doPull ? toApply.length : 0, - conflicts: merge.conflicts.length, - skipped, - durationMs: Date.now() - start, - provider: config.providerId, - summary, - }; - - this.outputChannel.appendLine( - `sync: dir=${direction} pushed=${result.pushed} pulled=${result.pulled} conflicts=${result.conflicts} skipped=${result.skipped} durationMs=${result.durationMs} provider=${result.provider} ` + - `push(+/~/-)=${formatCountsLine(summary.pushed)} pull(+/~/-)=${formatCountsLine(summary.pulled)}`, - ); - - TelemetryService.getInstance().trackEvent('sync_run', { - pushed: result.pushed, - pulled: result.pulled, - conflicts: result.conflicts, - skipped: result.skipped, - durationMs: result.durationMs, - provider: result.provider, - direction, - }); - - this.updateStatusBar(); - return result; - } catch (e) { - const isNetwork = e instanceof Error && /timeout|ECONNREFUSED|ENOTFOUND|network/i.test(e.message); - this.status = isNetwork ? 'offline' : 'error'; - this.backoffMs = Math.min(this.backoffMs * 2, SYNC_BACKOFF_MAX_MS); - await this.context.globalState.update( - SYNC_LAST_ERROR_KEY, - e instanceof Error ? e.message : String(e), + // Upserts — last-writer-wins, loser backed up locally. + for (const { meta, blob } of delta.upserts) { + if (excluded.has(meta.id)) { + continue; + } + const local = localById.get(meta.id); + const decision = decideIncoming( + !!local, + local ? index.isDirty(meta.id, local.meta.contentHash) : false, + local ? local.meta.contentHash === meta.contentHash : false, + local?.meta.updatedAt ?? 0, + meta.updatedAt, ); - this.updateStatusBar(); - TelemetryService.getInstance().trackEvent('sync_failure', { - failureClass: isNetwork ? 'network' : 'other', - provider: config.providerId, + if (decision.applyRemote) { + if (decision.backupLocal && local) { + this.backupLocal(local, meta.kind); + } + const plaintext = this.codec.decode(blob); + try { + await this.applyOne(meta, plaintext, config, connSvc, nbSvc, sqSvc); + applied += 1; + } catch (e) { + this.output.appendLine(`[sync] apply ${meta.id} failed: ${e instanceof Error ? e.message : String(e)}`); + } + } + // Always adopt the server version so we push the correct CAS base next. + index.markSynced(meta.id, { + kind: meta.kind, + contentHash: meta.contentHash, + version: meta.version, + updatedAt: meta.updatedAt, }); - - this.outputChannel.appendLine(`sync: failed ${e instanceof Error ? e.message : String(e)}`); - setTimeout(() => void this.runSync(), this.backoffMs); - return undefined; } - } - async previewSync(transientExcludedIds?: string[]): Promise { - return this.runSync({ dryRun: true, transientExcludedIds }) as Promise; + return applied; } - async pullOnly(): Promise { - return this.runSync({ direction: 'pull' }); + private async applyOne( + meta: RemoteItemMeta, + plaintext: Buffer, + config: SyncConfig, + connSvc: ConnectionSyncService, + nbSvc: NotebookSyncService, + sqSvc: SavedQueriesService, + ): Promise { + const data = JSON.parse(plaintext.toString()); + // Disk-mapping services predate v2; adapt the remote meta to their shape. + const shim: SyncItemMeta = { + id: meta.id, + kind: meta.kind, + contentHash: meta.contentHash, + revision: meta.version, + updatedAt: meta.updatedAt, + deviceId: meta.deviceId, + deleted: false, + }; + switch (meta.kind) { + case 'connection': + if (config.syncConnections) { + await connSvc.applyConnection(data, shim); + } + break; + case 'query': + if (config.syncQueries) { + await sqSvc.saveQuery({ ...data, id: meta.id }); + } + break; + case 'notebook': + if (config.syncNotebooks) { + await nbSvc.applyNotebook(data, shim); + } + break; + } + SyncActivityLog.getInstance(this.context).recordInbound({ + itemId: meta.id, + kind: meta.kind, + deviceId: meta.deviceId, + }); } - async pushOnly(): Promise { - return this.runSync({ direction: 'push' }); + /** Preserve a local copy that lost a conflict so nothing is silently dropped. */ + private backupLocal(local: LocalItem, kind: string): void { + try { + if (kind === 'notebook') { + const entry = new SyncIndex(this.context).get(local.meta.id); + const filePath = entry?.filePath; + if (filePath && fs.existsSync(filePath)) { + fs.copyFileSync(filePath, `${filePath}.backup-${Date.now()}`); + return; + } + } + if (kind === 'query') { + const data = JSON.parse(local.plaintext.toString()); + void SavedQueriesService.getInstance().saveQuery({ + ...data, + id: `${local.meta.id}-backup-${Date.now()}`, + title: `${data.title ?? 'Query'} (local backup)`, + }); + } + } catch (e) { + this.output.appendLine(`[sync] backup ${local.meta.id} failed: ${e instanceof Error ? e.message : String(e)}`); + } } - getLastSyncAt(): number | undefined { - return this.context.globalState.get(SYNC_LAST_SYNC_AT_KEY); + private acknowledgeActivities(index: SyncIndex): void { + // Clear pending entries whose local content matches what was last synced. + const log = SyncActivityLog.getInstance(this.context); + const acked: string[] = []; + for (const p of log.listPending()) { + const entry = index.get(p.itemId); + if (p.action === 'delete' && !entry) { + acked.push(`${p.kind}:${p.itemId}`); + } else if (entry && entry.syncedHash && entry.syncedHash === entry.lastObservedHash) { + acked.push(`${p.kind}:${p.itemId}`); + } + } + if (acked.length) { + log.acknowledge(acked); + } } - getLastError(): string | undefined { - return this.context.globalState.get(SYNC_LAST_ERROR_KEY); - } - - listInboundHistory(): InboundEntry[] { - return SyncActivityLog.getInstance(this.context).listInbound(); - } - - async getCloudQuota(): Promise { - const config = this.getConfig(); - if (config.providerId !== 'cloud') { - return undefined; - } - const provider = this.createProvider('cloud') as CloudSyncProvider; - return provider.getQuota(); - } - - async listCloudDevices(): Promise { - const config = this.getConfig(); - if (config.providerId !== 'cloud') { - return []; - } - const provider = this.createProvider('cloud') as CloudSyncProvider; - return provider.listDevices(); - } - - async revokeCloudDevice(deviceId: string): Promise { - const config = this.getConfig(); - if (config.providerId !== 'cloud') { - return false; - } - const provider = this.createProvider('cloud') as CloudSyncProvider; - return provider.revokeDevice(deviceId); - } + // ── Clear & re-sync (git-style hard reset) ──────────────────────────────────── + /** Wipe local synced state and pull everything fresh from the cloud. */ async replaceLocalWithCloud(): Promise { const config = this.getConfig(); if (!config.providerId) { return false; } - const vault = VaultService.getInstance(); - if (!vault.isUnlocked() && !(await vault.tryLoadCachedKey())) { - return false; - } - const provider = this.createProvider(config.providerId); - const index = new SyncIndex(this.context); - const remoteSnapshot = await provider.pull(); - const toApply: Array<{ meta: SyncItemMeta; plaintext: Buffer }> = []; - for (const meta of remoteSnapshot.manifest.filter((m) => !m.deleted)) { - const blob = await remoteSnapshot.getBlob(meta.id); - if (!blob) { - continue; + const release = await this.mutex.acquire(); + try { + this.setStatus('syncing'); + const index = new SyncIndex(this.context); + for (const id of Object.keys(index.getAll())) { + index.remove(id); } - toApply.push({ meta, plaintext: vault.decrypt(blob) }); + await index.flush(); + await this.setCursor(config, 0); + await this.runLocked(config, { direction: 'pull' }); + await this.context.globalState.update(SYNC_LAST_SYNC_AT_KEY, Date.now()); + this.setStatus('synced'); + return true; + } catch (e) { + this.output.appendLine(`[sync] replaceLocalWithCloud failed: ${e instanceof Error ? e.message : String(e)}`); + this.setStatus('error'); + return false; + } finally { + release(); } - await this.applyRemoteItems(toApply, config, index); - await this.setBaseManifest(remoteSnapshot.manifest); - index.markSynced(remoteSnapshot.manifest); - await index.flush(); - await this.context.globalState.update(SYNC_LAST_SYNC_AT_KEY, Date.now()); - this.status = 'synced'; - this.updateStatusBar(); - return true; } + /** Wipe cloud state and push everything from this device. */ async replaceCloudWithLocal(): Promise { const config = this.getConfig(); if (!config.providerId) { return false; } - const vault = VaultService.getInstance(); - if (!vault.isUnlocked() && !(await vault.tryLoadCachedKey())) { + const release = await this.mutex.acquire(); + try { + this.setStatus('syncing'); + const provider = this.getProvider(config); + await provider.resetSpace(); + const index = new SyncIndex(this.context); + for (const id of Object.keys(index.getAll())) { + index.update(id, { kind: index.get(id)!.kind, syncedHash: undefined, syncedVersion: undefined }); + } + await index.flush(); + await this.setCursor(config, 0); + await this.runLocked(config, { direction: 'push' }); + await this.context.globalState.update(SYNC_LAST_SYNC_AT_KEY, Date.now()); + this.setStatus('synced'); + return true; + } catch (e) { + this.output.appendLine(`[sync] replaceCloudWithLocal failed: ${e instanceof Error ? e.message : String(e)}`); + this.setStatus('error'); return false; + } finally { + release(); } - const deviceId = getOrCreateDeviceId(this.context); - const index = new SyncIndex(this.context); - const localItems = await this.collectLocalItems(config, deviceId, index); - const pushItems = localItems.map((i) => ({ - meta: i.meta, - blob: vault.encrypt(i.plaintext), - })); - const manifest = localItems.map((i) => i.meta); - const provider = this.createProvider(config.providerId); - await provider.push(pushItems, { manifest }); - await this.setBaseManifest(manifest); - index.markSynced(manifest); - await index.flush(); - await this.context.globalState.update(SYNC_LAST_SYNC_AT_KEY, Date.now()); - this.status = 'synced'; - this.updateStatusBar(); - return true; } - async rebuildSyncIndex(): Promise { - const config = this.getConfig(); - const deviceId = getOrCreateDeviceId(this.context); - const index = new SyncIndex(this.context); - for (const id of Object.keys(index.getAll())) { - index.remove(id); - } - const items = await this.collectLocalItems(config, deviceId, index); - for (const item of items) { - index.update(item.meta.id, { - kind: item.meta.kind, - name: this.resolveItemName(item.meta.id, item.meta.kind, index), - modifiedAt: item.meta.updatedAt, - syncedRevision: item.meta.revision, - lastObservedHash: item.meta.contentHash, - }); - } - await index.flush(); - return items.length; + // ── Settings-hub queries ─────────────────────────────────────────────────────── + + listPendingActivities() { + return SyncActivityLog.getInstance(this.context).listPending(); } - async runDiagnostics(): Promise { - const config = this.getConfig(); - const lines: string[] = ['PgStudio Sync Diagnostics', '========================']; - lines.push(`Status: ${this.status}`); - lines.push(`Provider: ${config.providerId ?? 'none'}`); - lines.push(`Vault unlocked: ${VaultService.getInstance().isUnlocked()}`); - lines.push(`Device id: ${getOrCreateDeviceId(this.context)}`); - lines.push(`Device name: ${getDeviceName(this.context) ?? '(unset)'}`); - lines.push(`Base manifest items: ${this.getBaseManifest().length}`); - lines.push(`Index items: ${Object.keys(new SyncIndex(this.context).getAll()).length}`); - lines.push(`Conflicts (last run): ${this.context.globalState.get(SYNC_LAST_CONFLICTS_KEY, []).length}`); - lines.push(`Conflict copies: ${this.countConflictCopies()}`); - lines.push(`Pending outbound: ${this.listPendingActivities().length}`); - lines.push(`Inbound history: ${this.listInboundHistory().length}`); - if (config.providerId) { - try { - const test = await this.createProvider(config.providerId).testConnection(); - lines.push(`Provider reachable: ${test.ok}${test.error ? ` (${test.error})` : ''}`); - } catch (e) { - lines.push(`Provider reachable: false (${e instanceof Error ? e.message : String(e)})`); - } - } - if (config.providerId === 'cloud') { - const quota = await this.getCloudQuota(); - if (quota) { - lines.push(`Cloud quota: ${quota.bytesUsed}/${quota.bytesLimit} bytes, ${quota.itemCount} items`); - } - } - const lastErr = this.getLastError(); - if (lastErr) { - lines.push(`Last error: ${lastErr}`); - } - this.outputChannel.appendLine(lines.join('\n')); - this.outputChannel.show(true); + listInboundHistory(): InboundEntry[] { + return SyncActivityLog.getInstance(this.context).listInbound(); } - async getItemPlaintext(id: string): Promise { + listSyncedItems(): SyncedItemView[] { const config = this.getConfig(); - const deviceId = getOrCreateDeviceId(this.context); const index = new SyncIndex(this.context); - const entry = index.get(id); - if (!entry) { - return undefined; - } - try { - if (entry.kind === 'query') { - const q = SavedQueriesService.getInstance().getQueries().find((x) => x.id === id); - return q ? JSON.stringify(q, null, 2) : undefined; - } - if (entry.kind === 'notebook') { - const items = await new NotebookSyncService(this.context, index).collectLocalNotebooks(deviceId); - const match = items.find((i) => i.meta.id === id); - return match ? match.plaintext.toString() : undefined; - } - } catch { - return undefined; - } - return undefined; - } - - schedulePushAfterConflict(): void { - this.schedulePush(); - } + const excluded = new Set(config.excludedIds ?? []); + const entries = index.getAll(); + const views: SyncedItemView[] = []; - private resolveItemName(id: string, kind: SyncItemMeta['kind'], index: SyncIndex): string | undefined { - const entry = index.get(id); - if (entry?.name) { - return entry.name; - } - if (kind === 'query') { - try { - const q = SavedQueriesService.getInstance().getQueries().find((x) => x.id === id); - return q?.title; - } catch { - return undefined; + const present = new Set(); + if (config.syncConnections) { + for (const c of vscode.workspace.getConfiguration().get('postgresExplorer.connections') || []) { + present.add(String(c.id)); } } - if (kind === 'connection') { - const connections = vscode.workspace.getConfiguration().get('postgresExplorer.connections') || []; - const conn = connections.find((c) => String(c.id) === id); - return conn?.name ?? `${conn?.host}:${conn?.port}`; + if (config.syncQueries) { + for (const q of SavedQueriesService.getInstance().getQueries()) { + present.add(q.id); + } } - return undefined; - } - private logInboundApplied( - items: Array<{ meta: SyncItemMeta; plaintext: Buffer }>, - index: SyncIndex, - ): void { - const log = SyncActivityLog.getInstance(this.context); - for (const { meta } of items) { - if (meta.deleted) { - continue; - } - log.recordInbound({ - itemId: meta.id, - kind: meta.kind, - name: this.resolveItemName(meta.id, meta.kind, index), - deviceId: meta.deviceId, - deviceName: undefined, + for (const [id, entry] of Object.entries(entries)) { + const synced = entry.syncedVersion != null; + const dirty = entry.lastObservedHash != null && entry.lastObservedHash !== entry.syncedHash; + views.push({ + id, + kind: entry.kind, + name: entry.name, + updatedAt: entry.modifiedAt ?? entry.syncedAt, + excluded: excluded.has(id), + itemStatus: excluded.has(id) ? 'excluded' : dirty ? 'pending' : synced ? 'synced' : 'local', }); } + return views; } - private countConflictCopies(): number { - return this.listSyncedItems().filter((i) => /-conflict-\d+$/.test(i.id)).length; + async setItemExcluded(id: string, excludedFlag: boolean): Promise { + const config = this.getConfig(); + const set = new Set(config.excludedIds ?? []); + if (excludedFlag) { + set.add(id); + } else { + set.delete(id); + } + await this.saveConfig({ ...config, excludedIds: [...set] }); } - /** - * Free-tier single-device enforcement. First device claims the backup; - * a different device must explicitly take it over (metered to 1/week so - * rebinding cannot be used as manual multi-device sync). - */ - private async ensureDeviceBinding(provider: SyncProvider, deviceId: string): Promise { - if (!provider.getBoundDeviceId || !provider.setBoundDeviceId) { - return true; - } - const bound = await provider.getBoundDeviceId(); - if (!bound) { - await provider.setBoundDeviceId(deviceId); - return true; - } - if (bound === deviceId) { - return true; + /** Stop syncing an item and delete it from the cloud (and other devices). */ + async removeFromCloud(id: string): Promise { + const config = this.getConfig(); + if (!config.providerId) { + return false; } - const choice = await vscode.window.showWarningMessage( - 'This backup belongs to another device. The free plan keeps backups bound to a single device — upgrade for multi-device sync, or claim the backup on this device (the other device will stop syncing).', - 'Claim Backup Here', - 'View Plans', - ); - if (choice === 'Claim Backup Here') { - if (!(await requirePro(ProFeature.SyncDeviceRebind))) { + const release = await this.mutex.acquire(); + try { + const index = new SyncIndex(this.context); + const entry = index.get(id); + if (!entry) { return false; } - await provider.setBoundDeviceId(deviceId); - return true; - } - if (choice === 'View Plans') { - void vscode.env.openExternal(vscode.Uri.parse('https://nexql.astrx.dev/#pricing')); - } - return false; - } - - private async collectLocalItems( - config: SyncConfig, - deviceId: string, - index: SyncIndex, - ): Promise> { - const items: Array<{ meta: SyncItemMeta; plaintext: Buffer }> = []; - - if (config.syncConnections) { - items.push(...new ConnectionSyncService(this.context, index).collectLocalConnections(deviceId)); - } - - if (config.syncQueries) { - const queries = SavedQueriesService.getInstance().getAllQueriesForSync(); - for (const q of queries) { - const plaintext = Buffer.from(JSON.stringify(q)); - items.push({ - meta: { - id: q.id, - kind: 'query', - contentHash: contentHash(plaintext), - revision: q.revision ?? 1, - updatedAt: q.updatedAt ?? q.createdAt, - deviceId, - deleted: !!q.deleted, - }, - plaintext, - }); + const provider = this.getProvider(config); + const result = await provider.pushBatch([ + { op: 'delete', itemId: id, kind: entry.kind, baseVersion: index.baseVersion(id) }, + ]); + if (result.accepted.length) { + index.remove(id); + await index.flush(); + await this.setCursor(config, result.cursor); + await this.setItemExcluded(id, true); + return true; } + return false; + } catch (e) { + this.output.appendLine(`[sync] removeFromCloud failed: ${e instanceof Error ? e.message : String(e)}`); + return false; + } finally { + release(); } - - if (config.syncNotebooks) { - items.push(...await new NotebookSyncService(this.context, index).collectLocalNotebooks(deviceId)); - } - - return items; } - /** - * Items present in the base manifest but missing from local collection were - * deleted on this device — synthesize tombstones so the deletion propagates. - * Queries manage their own tombstones; notebooks need on-disk evidence so a - * notebook that is merely outside the sync folder is not deleted remotely. - */ - private appendLocalTombstones( - baseManifest: SyncItemMeta[], - localItems: Array<{ meta: SyncItemMeta; plaintext: Buffer }>, - config: SyncConfig, - deviceId: string, - index: SyncIndex, - excluded: ReadonlySet = new Set(), - ): void { - const localIds = new Set(localItems.map((i) => `${i.meta.kind}:${i.meta.id}`)); - const nbSvc = new NotebookSyncService(this.context, index); - - for (const base of baseManifest) { - if (base.deleted || excluded.has(base.id) || localIds.has(`${base.kind}:${base.id}`)) { - continue; - } - if (base.kind === 'connection' && config.syncConnections) { - localItems.push({ meta: tombstoneMeta(base, deviceId), plaintext: Buffer.from('{}') }); - } else if (base.kind === 'notebook' && config.syncNotebooks && !nbSvc.isPresentOnDisk(base.id)) { - localItems.push({ meta: tombstoneMeta(base, deviceId), plaintext: Buffer.from('{}') }); - } + async rebuildSyncIndex(): Promise { + const index = new SyncIndex(this.context); + for (const id of Object.keys(index.getAll())) { + index.remove(id); } + await index.flush(); + const config = this.getConfig(); + await this.setCursor(config, 0); + const items = await this.collectLocalItems(config, new Set(config.excludedIds ?? []), index); + return items.length; } - private async applyRemoteItems( - items: Array<{ meta: SyncItemMeta; plaintext: Buffer }>, - config: SyncConfig, - index: SyncIndex, - ): Promise { - const connSvc = new ConnectionSyncService(this.context, index); - const nbSvc = new NotebookSyncService(this.context, index); - const sqSvc = SavedQueriesService.getInstance(); - - for (const { meta, plaintext } of items) { - if (meta.deleted) { - switch (meta.kind) { - case 'query': - await sqSvc.deleteQuery(meta.id); - break; - case 'connection': - if (config.syncConnections) { - await connSvc.removeConnection(meta); - } - break; - case 'notebook': - if (config.syncNotebooks) { - await nbSvc.deleteNotebook(meta); - } - break; - } - continue; - } - - const data = JSON.parse(plaintext.toString()); - - switch (meta.kind) { - case 'connection': - if (config.syncConnections) { - await connSvc.applyConnection(data, meta); - } - break; - case 'query': { - if (config.syncQueries) { - // Conflict copies arrive under a derived id; keep payload id in step. - const query = data.id === meta.id - ? data - : { ...data, id: meta.id, title: `${data.title ?? meta.id} (conflict from ${meta.deviceId})` }; - await sqSvc.saveQuery({ ...query, revision: meta.revision, updatedAt: meta.updatedAt }); - } - break; - } - - case 'notebook': - if (config.syncNotebooks) { - await nbSvc.applyNotebook(data, meta); - } - break; - case 'secrets': - if (config.syncPasswords) { - const { SecretStorageService } = await import('../../services/SecretStorageService'); - const secrets = SecretStorageService.getInstance(); - for (const [connId, password] of Object.entries(data.passwords ?? {})) { - await secrets.setPassword(connId, String(password)); - } - } - break; + async runDiagnostics(): Promise { + const config = this.getConfig(); + this.output.show(true); + this.output.appendLine('── PgStudio Sync diagnostics ──'); + this.output.appendLine(`provider: ${config.providerId ?? 'none'}`); + this.output.appendLine(`status: ${this.status} | conflicts: ${this.conflictCount}`); + this.output.appendLine(`cursor: ${this.getCursor(config)}`); + this.output.appendLine(`lastSyncAt: ${this.getLastSyncAt() ?? 'never'}`); + this.output.appendLine(`lastError: ${this.getLastError() ?? 'none'}`); + if (config.providerId) { + try { + const test = await this.getProvider(config).testConnection(); + this.output.appendLine(`connection: ${test.ok ? 'ok' : `failed — ${test.error}`}`); + } catch (e) { + this.output.appendLine(`connection: error — ${e instanceof Error ? e.message : String(e)}`); } } } - private updateStatusBar(): void { - const configured = !!this.getConfig().providerId; - this.statusBar?.updateSyncStatus(this.status, this.conflictCount, configured, { - lastSyncAt: this.getLastSyncAt(), - pendingCount: this.listPendingActivities().length, - }); - } - - /** - * Items known to sync on this device: base manifest (synced) plus local - * index (not yet pushed). Names come from local sources only — the remote - * manifest is zero-knowledge. - */ - listPendingActivities(): SyncActivityView[] { - return SyncActivityLog.getInstance(this.context).listPending(); - } - - private seedConnectionSnapshot(): void { - const connections = vscode.workspace.getConfiguration().get[]>( - 'postgresExplorer.connections', - [], - ); - this.connectionSnapshot = new Map( - connections.map((c) => [String(c.id), SyncController.connectionFingerprint(c)]), - ); - } - - private static connectionFingerprint(conn: Record): string { - const copy = { ...conn }; - delete copy.password; - return JSON.stringify(copy); - } + // ── Cloud-only helpers ────────────────────────────────────────────────────────── - private static connectionNameFromFingerprint(fingerprint: string): string | undefined { - try { - const parsed = JSON.parse(fingerprint) as { name?: string }; - return parsed.name; - } catch { + async getCloudQuota(): Promise { + const config = this.getConfig(); + if (config.providerId !== 'cloud') { return undefined; } + return (this.getProvider(config) as CloudSyncProvider).getQuota(); } - private recordOpenNotebookActivity( - doc: vscode.NotebookDocument, - action: 'create' | 'update', - ): void { - if (doc.notebookType !== 'postgres-notebook' && doc.notebookType !== 'postgres-query') { - return; - } - if (doc.isUntitled || doc.uri.scheme !== 'file') { - return; - } - const metadata = doc.metadata as Record; - const syncId = typeof metadata.syncId === 'string' ? metadata.syncId : undefined; - if (!syncId) { - return; - } - recordSyncActivity({ - kind: 'notebook', - action, - itemId: syncId, - name: doc.uri.path.split('/').pop()?.replace(/\.pgsql$/i, ''), - }); - } - - private recordConnectionConfigChanges(): boolean { - const connections = vscode.workspace.getConfiguration().get[]>( - 'postgresExplorer.connections', - [], - ); - if (!this.connectionSnapshot) { - this.connectionSnapshot = new Map( - connections.map((c) => [String(c.id), SyncController.connectionFingerprint(c)]), - ); - return false; - } - let changed = false; - const next = new Map( - connections.map((c) => [String(c.id), SyncController.connectionFingerprint(c)]), - ); - for (const conn of connections) { - const id = String(conn.id); - const fp = next.get(id)!; - const prev = this.connectionSnapshot.get(id); - const name = typeof conn.name === 'string' ? conn.name : undefined; - if (!prev) { - recordSyncActivity({ kind: 'connection', action: 'create', itemId: id, name }); - changed = true; - } else if (prev !== fp) { - const prevName = SyncController.connectionNameFromFingerprint(prev); - recordSyncActivity({ - kind: 'connection', - action: prevName !== name ? 'rename' : 'update', - itemId: id, - name, - previousName: prevName !== name ? prevName : undefined, - }); - changed = true; - } - } - for (const [id, fp] of this.connectionSnapshot) { - if (!next.has(id)) { - recordSyncActivity({ - kind: 'connection', - action: 'delete', - itemId: id, - name: SyncController.connectionNameFromFingerprint(fp), - }); - changed = true; - } - } - this.connectionSnapshot = next; - return changed; - } - - private connectionSnapshot: Map | undefined; - - private async recordNotebookFileActivity( - uri: vscode.Uri, - action: 'create' | 'update', - ): Promise { - try { - const raw = await vscode.workspace.fs.readFile(uri); - const parsed = JSON.parse(Buffer.from(raw).toString()) as Record; - const syncId = readNotebookSyncId(parsed); - if (!syncId) { - return; - } - const name = uri.path.split('/').pop()?.replace(/\.pgsql$/i, ''); - recordSyncActivity({ - kind: 'notebook', - action, - itemId: syncId, - name, - }); - } catch { - /* unreadable notebook file */ - } - } - - private recordNotebookFileDelete(uri: vscode.Uri): void { - const index = new SyncIndex(this.context); - const match = index.findByPath(uri.fsPath); - if (match) { - recordSyncActivity({ - kind: 'notebook', - action: 'delete', - itemId: match.id, - name: match.entry.name ?? uri.path.split('/').pop()?.replace(/\.pgsql$/i, ''), - }); - return; - } - const name = uri.path.split('/').pop()?.replace(/\.pgsql$/i, ''); - if (name) { - recordSyncActivity({ - kind: 'notebook', - action: 'delete', - itemId: uri.fsPath, - name, - }); + async listCloudDevices(): Promise { + const config = this.getConfig(); + if (config.providerId !== 'cloud') { + return []; } + return (this.getProvider(config) as CloudSyncProvider).listDevices(); } - listSyncedItems(): SyncedItemView[] { + async revokeCloudDevice(deviceId: string): Promise { const config = this.getConfig(); - const excluded = new Set(config.excludedIds ?? []); - const index = new SyncIndex(this.context); - const indexEntries = index.getAll(); - const baseManifest = this.getBaseManifest(); - const baseActiveIds = new Set(baseManifest.filter((m) => !m.deleted).map((m) => m.id)); - const nbSvc = new NotebookSyncService(this.context, index); - const byId = new Map(); - - for (const meta of baseManifest) { - byId.set(meta.id, { - id: meta.id, - kind: meta.kind, - name: indexEntries[meta.id]?.name, - updatedAt: meta.updatedAt, - deviceId: meta.deviceId, - revision: meta.revision, - excluded: excluded.has(meta.id), - deleted: meta.deleted, - }); - } - for (const [id, entry] of Object.entries(indexEntries)) { - if (!byId.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), - deleted: false, - }); - } - } - - try { - for (const q of SavedQueriesService.getInstance().getQueries()) { - const view = byId.get(q.id); - if (view && !view.name) { - view.name = q.title; - } - } - } catch { - /* saved queries unavailable — ids shown instead */ - } - const connections = vscode.workspace.getConfiguration().get('postgresExplorer.connections') || []; - for (const conn of connections) { - const view = byId.get(String(conn.id)); - if (view && !view.name) { - view.name = conn.name ?? `${conn.host}:${conn.port}`; - } + if (config.providerId !== 'cloud') { + return false; } - - return [...byId.values()] - .filter((v) => { - if (v.deleted) { - return false; - } - if (v.kind === 'notebook' && !baseActiveIds.has(v.id) && !nbSvc.isPresentOnDisk(v.id)) { - return false; - } - return true; - }) - .sort((a, b) => a.kind.localeCompare(b.kind) || (a.name ?? a.id).localeCompare(b.name ?? b.id)); + return (this.getProvider(config) as CloudSyncProvider).revokeDevice(deviceId); } - /** Drop index rows for notebooks that are gone locally and no longer active in sync. */ - private purgeStaleNotebookIndex(index: SyncIndex, manifest: SyncItemMeta[]): void { - const activeIds = new Set( - manifest.filter((m) => !m.deleted && m.kind === 'notebook').map((m) => m.id), - ); - const nbSvc = new NotebookSyncService(this.context, index); - for (const id of Object.keys(index.getAll())) { - const entry = index.get(id); - if (entry?.kind !== 'notebook') { - continue; - } - if (activeIds.has(id) || nbSvc.isPresentOnDisk(id)) { - continue; - } - index.remove(id); - } - } + // ── Sharing support (read item content for workspace sharing) ──────────────────── - /** - * Resolve a local shareable item (query or notebook) to its raw payload for - * sharing. Connections and secrets are never shareable and return undefined. - */ async getShareableItem(id: string): Promise<{ kind: 'query' | 'notebook'; raw: Record; name: string } | undefined> { - try { - const query = SavedQueriesService.getInstance().getQueries().find((q) => q.id === id); - if (query) { - return { kind: 'query', raw: query as unknown as Record, name: query.title }; - } - } catch { - /* saved queries unavailable */ - } - const index = new SyncIndex(this.context); - const entry = index.get(id); - if (entry?.kind === 'notebook') { - const deviceId = getOrCreateDeviceId(this.context); - const items = await new NotebookSyncService(this.context, index).collectLocalNotebooks(deviceId); - const match = items.find((i) => i.meta.id === id); - if (match) { - const raw = JSON.parse(match.plaintext.toString()) as Record; - return { kind: 'notebook', raw, name: (raw.name as string) ?? entry.name ?? 'Notebook' }; - } + const query = SavedQueriesService.getInstance().getQuery(id); + if (query) { + return { kind: 'query', raw: query as unknown as Record, name: query.title }; + } + const entry = new SyncIndex(this.context).get(id); + if (entry?.kind === 'notebook' && entry.filePath && fs.existsSync(entry.filePath)) { + const parsed = JSON.parse(fs.readFileSync(entry.filePath).toString()); + return { + kind: 'notebook', + raw: { name: entry.name ?? id, cells: parsed.cells ?? [], databaseName: parsed.metadata?.databaseName }, + name: entry.name ?? id, + }; } return undefined; } - /** Exclude/re-include an item from sync on this device. */ - async setItemExcluded(id: string, excludedFlag: boolean): Promise { - const config = this.getConfig(); - const set = new Set(config.excludedIds ?? []); - if (excludedFlag) { - set.add(id); - } else { - set.delete(id); - } - await this.saveConfig({ ...config, excludedIds: [...set] }); - } - - /** - * Delete the remote copy (tombstone — other devices remove theirs on next - * pull) while keeping the local copy and excluding it from future sync. - */ - async removeFromCloud(id: string): Promise { - const config = this.getConfig(); - if (!config.providerId) { - return false; - } - const vault = VaultService.getInstance(); - if (!vault.isUnlocked() && !(await vault.tryLoadCachedKey())) { - return false; - } - const base = this.getBaseManifest(); - const meta = base.find((m) => m.id === id && !m.deleted); - if (!meta) { - return false; - } - const deviceId = getOrCreateDeviceId(this.context); - const tombstone = tombstoneMeta(meta, deviceId); - const newBase = base.map((m) => (m.id === id ? tombstone : m)); - const provider = this.createProvider(config.providerId); - await provider.push( - [{ meta: tombstone, blob: vault.encrypt(Buffer.from('{}')) }], - { manifest: newBase }, - ); - await this.setBaseManifest(newBase); - const index = new SyncIndex(this.context); - index.remove(id); - await index.flush(); - await this.setItemExcluded(id, true); - return true; + async getItemPlaintext(id: string): Promise { + const item = await this.getShareableItem(id); + return item ? JSON.stringify(item.raw, null, 2) : undefined; } async signOut(): Promise { - await VaultService.getInstance().signOut(); - await AccountService.getInstance().signOut(); - if (this.getConfig().providerId === 'gist') { - const { GistSyncProvider } = await import('./providers/GistSyncProvider'); - await GistSyncProvider.clearStoredGistId(this.context); + const config = this.getConfig(); + if (config.providerId === 'cloud') { + await (await import('./AccountService')).AccountService.getInstance(this.context).signOut(); } - await this.saveConfig({ - ...this.getConfig(), - providerId: undefined, - gistId: undefined, - paused: false, - }); - SyncActivityLog.getInstance(this.context).clearAll(); - this.connectionSnapshot = undefined; - this.status = 'not_configured'; - this.updateStatusBar(); + await this.saveConfig({ ...config, providerId: undefined, paused: false }); + this.stopPeriodicPull(); + this.setStatus('not_configured'); } dispose(): void { if (this.debounceTimer) { clearTimeout(this.debounceTimer); } - if (this.periodicTimer) { - clearInterval(this.periodicTimer); + this.stopPeriodicPull(); + for (const d of this.disposables) { + d.dispose(); } - this.disposables.forEach((d) => d.dispose()); + this.disposables = []; } } diff --git a/src/features/sync/SyncEngine.ts b/src/features/sync/SyncEngine.ts deleted file mode 100644 index 347556f..0000000 --- a/src/features/sync/SyncEngine.ts +++ /dev/null @@ -1,331 +0,0 @@ -import type { MergeConflict, MergeResult, SyncItemMeta, SyncPushItem } from './types'; -import { TOMBSTONE_RETENTION_MS } from './constants'; - -export interface LocalItem { - meta: SyncItemMeta; - plaintext: Buffer; -} - -/** Remote item with lazy blob access — blobs are only fetched when applied. */ -export interface RemoteItem { - meta: SyncItemMeta; - getBlob(): Promise; -} - -function metaKey(m: SyncItemMeta): string { - return `${m.kind}:${m.id}`; -} - -/** Compare revisions: primary comparator revision, tiebreaker updatedAt. */ -export function compareRevisions(a: SyncItemMeta, b: SyncItemMeta): number { - if (a.revision !== b.revision) { - return a.revision - b.revision; - } - return a.updatedAt - b.updatedAt; -} - -export function pickWinner(local: SyncItemMeta, remote: SyncItemMeta): 'local' | 'remote' { - const cmp = compareRevisions(local, remote); - return cmp >= 0 ? 'local' : 'remote'; -} - -function isTombstoneExpired(meta: SyncItemMeta, now: number): boolean { - return meta.deleted && now - meta.updatedAt > TOMBSTONE_RETENTION_MS; -} - -function pruneTombstones(manifest: SyncItemMeta[], now: number): SyncItemMeta[] { - return manifest.filter((m) => !isTombstoneExpired(m, now)); -} - -/** - * Three-way merge: base manifest vs local vs remote. - * Pure logic — no vscode imports. Remote blobs are fetched lazily and only - * for items that actually need to be applied locally. - */ -export async function mergeSyncState( - baseManifest: SyncItemMeta[], - localItems: LocalItem[], - remoteItems: RemoteItem[], - deviceId: string, - decrypt: (blob: Buffer) => Buffer, - now = Date.now(), -): Promise { - const baseByKey = new Map(baseManifest.map((m) => [metaKey(m), m])); - const localByKey = new Map(localItems.map((i) => [metaKey(i.meta), i])); - const remoteByKey = new Map(remoteItems.map((i) => [metaKey(i.meta), i])); - - const allKeys = new Set([ - ...baseByKey.keys(), - ...localByKey.keys(), - ...remoteByKey.keys(), - ]); - - const toPush: SyncPushItem[] = []; - const toApply: Array<{ meta: SyncItemMeta; plaintext: Buffer }> = []; - const conflicts: MergeConflict[] = []; - const skipped: Array<{ id: string; reason: string }> = []; - const newBaseEntries: SyncItemMeta[] = []; - - const isFirstSync = baseManifest.length === 0; - - /** Fetch + decrypt a remote blob; tombstones never need content. */ - const remotePlaintext = async (item: RemoteItem): Promise => { - if (item.meta.deleted) { - return Buffer.alloc(0); - } - const blob = await item.getBlob(); - return blob ? decrypt(blob) : undefined; - }; - - for (const key of allKeys) { - const base = baseByKey.get(key); - let local = localByKey.get(key); - let remote = remoteByKey.get(key); - - // Expired tombstones are treated as absent — they must not drop a live - // item on the other side. - if (local?.meta.deleted && isTombstoneExpired(local.meta, now)) { - local = undefined; - } - if (remote?.meta.deleted && isTombstoneExpired(remote.meta, now)) { - remote = undefined; - } - - if (!local && !remote) { - if (base && !isTombstoneExpired(base, now)) { - newBaseEntries.push(base); - } - continue; - } - - if (isFirstSync) { - if (local && remote) { - if (local.meta.contentHash === remote.meta.contentHash) { - // Identical content already on both sides (e.g. fresh device whose - // files carry embedded sync ids). Adopt the higher revision; nothing - // to transfer. - newBaseEntries.push(pickWinner(local.meta, remote.meta) === 'local' ? local.meta : remote.meta); - continue; - } - const winner = pickWinner(local.meta, remote.meta); - const loser = winner === 'local' ? remote : local; - const loserPlain = winner === 'local' ? await remotePlaintext(remote) : local.plaintext; - const winnerPlain = winner === 'local' ? local.plaintext : await remotePlaintext(remote); - if (winnerPlain === undefined || loserPlain === undefined) { - skipped.push({ id: local.meta.id, reason: 'missing remote blob' }); - continue; - } - const conflictName = `${loser.meta.id} (conflict from ${loser.meta.deviceId})`; - conflicts.push({ - id: loser.meta.id, - kind: loser.meta.kind, - localName: conflictName, - remoteDeviceId: loser.meta.deviceId, - winner, - loserCopyName: conflictName, - }); - const winnerMeta = winner === 'local' ? local.meta : remote.meta; - if (winner === 'local') { - toPush.push({ meta: local.meta, blob: Buffer.alloc(0) }); - } else { - toApply.push({ meta: winnerMeta, plaintext: winnerPlain }); - } - toApply.push({ - meta: { - ...loser.meta, - id: `${loser.meta.id}-conflict-${now}`, - updatedAt: now, - deviceId, - }, - plaintext: loserPlain, - }); - newBaseEntries.push(winnerMeta); - continue; - } - if (local) { - toPush.push({ meta: local.meta, blob: Buffer.alloc(0) }); - newBaseEntries.push(local.meta); - } - if (remote) { - const plain = await remotePlaintext(remote); - if (plain === undefined) { - skipped.push({ id: remote.meta.id, reason: 'missing remote blob' }); - continue; - } - toApply.push({ meta: remote.meta, plaintext: plain }); - newBaseEntries.push(remote.meta); - } - continue; - } - - const localChanged = local - ? !base || local.meta.contentHash !== base.contentHash || local.meta.revision !== base.revision || local.meta.deleted !== base.deleted - : false; - const remoteChanged = remote - ? !base || remote.meta.revision > (base?.revision ?? 0) || remote.meta.contentHash !== base.contentHash || remote.meta.deleted !== (base?.deleted ?? false) - : false; - - if (!localChanged && !remoteChanged) { - if (base) { - newBaseEntries.push(base); - } else if (local && remote && local.meta.contentHash === remote.meta.contentHash) { - newBaseEntries.push(pickWinner(local.meta, remote.meta) === 'local' ? local.meta : remote.meta); - } - continue; - } - - if (localChanged && !remoteChanged) { - if (local) { - toPush.push({ meta: local.meta, blob: Buffer.alloc(0) }); - newBaseEntries.push(local.meta); - } - continue; - } - - if (!localChanged && remoteChanged) { - if (remote) { - const plain = await remotePlaintext(remote); - if (plain === undefined) { - skipped.push({ id: remote.meta.id, reason: 'missing remote blob' }); - if (base) { - newBaseEntries.push(base); - } - continue; - } - toApply.push({ meta: remote.meta, plaintext: plain }); - newBaseEntries.push(remote.meta); - } - continue; - } - - // Both changed from here on. - if (local && remote && local.meta.contentHash === remote.meta.contentHash && local.meta.deleted === remote.meta.deleted) { - newBaseEntries.push(pickWinner(local.meta, remote.meta) === 'local' ? local.meta : remote.meta); - continue; - } - - // Edit-vs-delete: the edit wins; no conflict copy of a tombstone. - if (local && remote && local.meta.deleted !== remote.meta.deleted) { - if (local.meta.deleted) { - const plain = await remotePlaintext(remote); - if (plain === undefined) { - skipped.push({ id: remote.meta.id, reason: 'missing remote blob' }); - if (base) { - newBaseEntries.push(base); - } - continue; - } - toApply.push({ meta: remote.meta, plaintext: plain }); - newBaseEntries.push(remote.meta); - } else { - toPush.push({ meta: local.meta, blob: Buffer.alloc(0) }); - newBaseEntries.push(local.meta); - } - continue; - } - - if (local && remote) { - const winner = pickWinner(local.meta, remote.meta); - const remotePlain = await remotePlaintext(remote); - if (remotePlain === undefined) { - skipped.push({ id: remote.meta.id, reason: 'missing remote blob' }); - if (base) { - newBaseEntries.push(base); - } - continue; - } - const winnerMeta = winner === 'local' ? local.meta : remote.meta; - const loserMeta = winner === 'local' ? remote.meta : local.meta; - const loserPlain = winner === 'local' ? remotePlain : local.plaintext; - - conflicts.push({ - id: local.meta.id, - kind: local.meta.kind, - localName: local.meta.id, - remoteDeviceId: remote.meta.deviceId, - winner, - loserCopyName: `${local.meta.id} (conflict from ${remote.meta.deviceId})`, - }); - - if (winner === 'local') { - toPush.push({ meta: local.meta, blob: Buffer.alloc(0) }); - } else { - toApply.push({ meta: remote.meta, plaintext: remotePlain }); - } - toApply.push({ - meta: { - ...loserMeta, - id: `${loserMeta.id}-conflict-${now}`, - revision: Math.max(local.meta.revision, remote.meta.revision), - updatedAt: now, - deviceId, - }, - plaintext: loserPlain, - }); - newBaseEntries.push(winnerMeta); - continue; - } - - // Only one side present and changed. - if (local && localChanged) { - toPush.push({ meta: local.meta, blob: Buffer.alloc(0) }); - newBaseEntries.push(local.meta); - } else if (remote && remoteChanged) { - const plain = await remotePlaintext(remote); - if (plain === undefined) { - skipped.push({ id: remote.meta.id, reason: 'missing remote blob' }); - if (base) { - newBaseEntries.push(base); - } - continue; - } - toApply.push({ meta: remote.meta, plaintext: plain }); - newBaseEntries.push(remote.meta); - } - } - - return { - toPush, - toApply, - conflicts, - skipped, - newBaseManifest: pruneTombstones(newBaseEntries, now), - }; -} - -/** Attach encrypted blobs to push items. */ -export function attachEncryptedBlobs( - toPush: SyncPushItem[], - localItems: LocalItem[], - encrypt: (plaintext: Buffer) => Buffer, -): SyncPushItem[] { - const localByKey = new Map(localItems.map((i) => [metaKey(i.meta), i])); - return toPush.map((item) => { - const local = localByKey.get(metaKey(item.meta)); - if (!local) { - return item; - } - return { meta: item.meta, blob: encrypt(local.plaintext) }; - }); -} - -export function bumpRevision(meta: SyncItemMeta, deviceId: string, contentHash: string, now = Date.now()): SyncItemMeta { - return { - ...meta, - revision: meta.revision + 1, - updatedAt: now, - deviceId, - contentHash, - deleted: false, - }; -} - -export function tombstoneMeta(meta: SyncItemMeta, deviceId: string, now = Date.now()): SyncItemMeta { - return { - ...meta, - deleted: true, - revision: meta.revision + 1, - updatedAt: now, - deviceId, - }; -} diff --git a/src/features/sync/SyncEngineV2.ts b/src/features/sync/SyncEngineV2.ts new file mode 100644 index 0000000..e0adabe --- /dev/null +++ b/src/features/sync/SyncEngineV2.ts @@ -0,0 +1,56 @@ +/** + * Pure merge helpers for the git-like sync engine. No VS Code or IO deps so the + * decision logic is unit-testable in isolation. + * + * The engine never does a three-way merge. The server is the source of truth; + * the client only ever decides, per item, whether a *local* edit or the + * *remote* version wins — by last-writer-wins on edit time. The loser is always + * preserved as a local backup by the caller, so nothing is silently dropped. + */ + +export type Side = 'local' | 'remote'; + +/** Last-writer-wins: newer edit time wins; ties go to remote (server authority). */ +export function pickWinner(localUpdatedAt: number, remoteUpdatedAt: number): Side { + return localUpdatedAt > remoteUpdatedAt ? 'local' : 'remote'; +} + +export interface IncomingDecision { + /** Apply the remote blob locally. */ + applyRemote: boolean; + /** Back up the current local copy before applying (it had unsynced edits). */ + backupLocal: boolean; +} + +/** + * Decide what to do with one incoming remote upsert. + * + * @param hasLocal a local item with this id exists + * @param localDirty the local item has edits not yet pushed + * @param sameContent local and remote content hashes match + * @param localUpdatedAt local edit time (epoch ms) + * @param remoteUpdatedAt remote write time (epoch ms) + */ +export function decideIncoming( + hasLocal: boolean, + localDirty: boolean, + sameContent: boolean, + localUpdatedAt: number, + remoteUpdatedAt: number, +): IncomingDecision { + if (!hasLocal) { + return { applyRemote: true, backupLocal: false }; + } + if (sameContent) { + // Already converged — just adopt the server version, no write needed. + return { applyRemote: false, backupLocal: false }; + } + if (!localDirty) { + // Fast-forward: local is unchanged since last sync, take remote. + return { applyRemote: true, backupLocal: false }; + } + // Genuine conflict — both sides changed. LWW, preserve the loser locally. + return pickWinner(localUpdatedAt, remoteUpdatedAt) === 'remote' + ? { applyRemote: true, backupLocal: true } + : { applyRemote: false, backupLocal: false }; +} diff --git a/src/features/sync/SyncIndex.ts b/src/features/sync/SyncIndex.ts index efa02c2..cf1226b 100644 --- a/src/features/sync/SyncIndex.ts +++ b/src/features/sync/SyncIndex.ts @@ -1,16 +1,18 @@ +import * as path from 'path'; import type * as vscode from 'vscode'; import { SYNC_ITEM_INDEX_KEY } from './constants'; -import type { SyncItemMeta, SyncKind } from './types'; +import type { SyncKind } from './types'; export interface SyncIndexEntry { kind: SyncKind; - /** Display name (local-only; never uploaded in plaintext). */ + /** Display name (local-only). */ name?: string; /** Absolute path of the backing file (notebooks only). */ filePath?: string; - /** Revision confirmed by the last successful sync (0 = never synced). */ - syncedRevision: number; + /** Content hash confirmed by the last successful sync. */ syncedHash?: string; + /** Server version confirmed by the last successful sync (compare-and-swap base). */ + syncedVersion?: number; syncedAt?: number; /** Last content hash observed during collection. */ lastObservedHash?: string; @@ -18,36 +20,19 @@ export interface SyncIndexEntry { modifiedAt?: number; } +/** What a single observe() call reports back to the disk-mapping services. */ export interface ObservedRevision { + /** Vestigial; the engine orders by server version + hash. Kept for callers. */ revision: number; + /** Local edit time (epoch ms) — used for last-writer-wins resolution. */ updatedAt: number; } -/** - * Pure revision decision: stable identity across sync runs. - * - Unchanged since last sync → reuse synced revision/timestamp. - * - Changed → synced revision + 1 (idempotent until the next successful sync). - */ -export function decideRevision( - entry: SyncIndexEntry | undefined, - currentHash: string, - fallbackRevision: number | undefined, - now: number, -): ObservedRevision { - if (!entry || entry.syncedRevision === 0) { - return { revision: Math.max(1, fallbackRevision ?? 1), updatedAt: entry?.modifiedAt ?? now }; - } - if (currentHash === entry.syncedHash) { - return { revision: entry.syncedRevision, updatedAt: entry.syncedAt ?? now }; - } - const modifiedAt = currentHash === entry.lastObservedHash ? (entry.modifiedAt ?? now) : now; - return { revision: entry.syncedRevision + 1, updatedAt: modifiedAt }; -} - /** * Local item index keyed by sync id. Tracks file locations, display names and - * per-item revisions so identity stays stable across devices and sync runs. - * Kept in globalState; mutate via observe/update/markSynced then flush(). + * the last-synced content hash + server version, so the git-like engine can + * tell what is dirty and what compare-and-swap base to push with. Kept in + * globalState; mutate via observe/update/markSynced/remove then flush(). */ export class SyncIndex { private entries: Record; @@ -66,17 +51,37 @@ export class SyncIndex { } findByPath(filePath: string): { id: string; entry: SyncIndexEntry } | undefined { + const target = path.resolve(filePath); for (const [id, entry] of Object.entries(this.entries)) { - if (entry.filePath === filePath) { + if (entry.filePath && path.resolve(entry.filePath) === target) { return { id, entry }; } } return undefined; } + /** Server version to push as the compare-and-swap base (0 = never synced). */ + baseVersion(id: string): number { + return this.entries[id]?.syncedVersion ?? 0; + } + + /** True when the local content differs from what was last pushed. */ + isDirty(id: string, currentHash: string): boolean { + const entry = this.entries[id]; + return !entry || entry.syncedHash !== currentHash; + } + + /** Ids that have been synced at least once (used to detect local deletions). */ + syncedIds(): string[] { + return Object.entries(this.entries) + .filter(([, e]) => e.syncedVersion != null) + .map(([id]) => id); + } + /** - * Record an observation of a local item and return the revision/updatedAt - * to advertise in its sync meta. + * Record an observation of a local item and return the revision/updatedAt to + * advertise in its sync meta. `updatedAt` is held stable while content is + * unchanged and bumped to "now" the first time changed content appears. */ observe( id: string, @@ -86,26 +91,34 @@ export class SyncIndex { now = Date.now(), ): ObservedRevision { const existing = this.entries[id]; - const decision = decideRevision(existing, currentHash, opts.fallbackRevision, opts.modifiedAt ?? now); + let updatedAt: number; + if (existing && currentHash === existing.syncedHash) { + updatedAt = existing.syncedAt ?? now; + } else if (existing && currentHash === existing.lastObservedHash) { + updatedAt = existing.modifiedAt ?? opts.modifiedAt ?? now; + } else { + updatedAt = opts.modifiedAt ?? now; + } + const next: SyncIndexEntry = { kind, name: opts.name ?? existing?.name, filePath: opts.filePath ?? existing?.filePath, - syncedRevision: existing?.syncedRevision ?? 0, syncedHash: existing?.syncedHash, + syncedVersion: existing?.syncedVersion, syncedAt: existing?.syncedAt, lastObservedHash: currentHash, - modifiedAt: currentHash === existing?.lastObservedHash ? existing?.modifiedAt : (opts.modifiedAt ?? now), + modifiedAt: updatedAt, }; if (JSON.stringify(next) !== JSON.stringify(existing)) { this.entries[id] = next; this.dirty = true; } - return decision; + return { revision: existing?.syncedVersion ?? 0, updatedAt }; } update(id: string, patch: Partial & { kind: SyncKind }): void { - const existing: SyncIndexEntry = this.entries[id] ?? { kind: patch.kind, syncedRevision: 0 }; + const existing: SyncIndexEntry = this.entries[id] ?? { kind: patch.kind }; this.entries[id] = { ...existing, ...patch }; this.dirty = true; } @@ -117,23 +130,20 @@ export class SyncIndex { } } - /** Record the outcome of a successful sync run. */ - markSynced(manifest: SyncItemMeta[]): void { - for (const meta of manifest) { - if (meta.deleted) { - this.remove(meta.id); - continue; - } - const existing: SyncIndexEntry = this.entries[meta.id] ?? { kind: meta.kind, syncedRevision: 0 }; - this.entries[meta.id] = { - ...existing, - syncedRevision: meta.revision, - syncedHash: meta.contentHash, - syncedAt: meta.updatedAt, - lastObservedHash: meta.contentHash, - }; - this.dirty = true; - } + /** Record a successful sync of one item at the given server version. */ + markSynced(id: string, fields: { kind: SyncKind; contentHash: string; version: number; updatedAt?: number; name?: string; filePath?: string }): void { + const existing: SyncIndexEntry = this.entries[id] ?? { kind: fields.kind }; + this.entries[id] = { + ...existing, + kind: fields.kind, + name: fields.name ?? existing.name, + filePath: fields.filePath ?? existing.filePath, + syncedHash: fields.contentHash, + syncedVersion: fields.version, + syncedAt: fields.updatedAt ?? Date.now(), + lastObservedHash: fields.contentHash, + }; + this.dirty = true; } async flush(): Promise { diff --git a/src/features/sync/SyncMutex.ts b/src/features/sync/SyncMutex.ts new file mode 100644 index 0000000..e6966a3 --- /dev/null +++ b/src/features/sync/SyncMutex.ts @@ -0,0 +1,28 @@ +/** + * Minimal async mutex — serializes sync runs without the fragile promise-tail + * chaining the old controller used. acquire() resolves with a release fn; always + * release in a finally. + */ +export class SyncMutex { + private tail: Promise = Promise.resolve(); + + async acquire(): Promise<() => void> { + let release!: () => void; + const next = new Promise((resolve) => { + release = resolve; + }); + const previous = this.tail; + this.tail = this.tail.then(() => next); + await previous; + return release; + } + + /** True when no run is in flight (best-effort, for status checks). */ + get isLocked(): boolean { + let settled = false; + void this.tail.then(() => { + settled = true; + }); + return !settled; + } +} diff --git a/src/features/sync/SyncSetupWizard.ts b/src/features/sync/SyncSetupWizard.ts index 658a2d6..f9f9347 100644 --- a/src/features/sync/SyncSetupWizard.ts +++ b/src/features/sync/SyncSetupWizard.ts @@ -3,10 +3,6 @@ import { LicenseService } from '../../services/LicenseService'; import { TIER_DISPLAY, allowedSyncProviders, syncProviderMinTier } from '../../services/featureGates'; import { SyncController } from './SyncController'; import { AccountService } from './AccountService'; -import { VaultService } from './VaultService'; -import { GistSyncProvider } from './providers/GistSyncProvider'; -import { OneDriveSyncProvider } from './providers/OneDriveSyncProvider'; -import { GoogleDriveSyncProvider } from './providers/GoogleDriveSyncProvider'; import { CloudSyncProvider } from './providers/CloudSyncProvider'; import { PostgresSyncProvider } from './providers/PostgresSyncProvider'; import { ensureDeviceName } from './deviceId'; @@ -22,7 +18,9 @@ export interface WizardCompleteResult { export type CloudSignInMode = 'license' | 'browser'; /** - * Settings-hub onboarding wizard — cloud-first path with Advanced backends. + * Settings-hub onboarding wizard. Pass 1 has no vault step — sign in, pick a + * backend (Cloud or self-hosted Postgres), and run the first sync. Empty remote + * pushes local; existing remote pulls. */ export class SyncSetupWizard { constructor(private readonly context: vscode.ExtensionContext) {} @@ -52,126 +50,57 @@ export class SyncSetupWizard { } async testBackend(providerId: SyncProviderId): Promise<{ ok: boolean; error?: string }> { - const provider = this.createProvider(providerId); - if (providerId === 'gist') { - await (provider as GistSyncProvider).ensureAuth(); - } else if (providerId === 'onedrive') { - await (provider as OneDriveSyncProvider).ensureAuth(); - } else if (providerId === 'gdrive') { - await (provider as GoogleDriveSyncProvider).ensureAuth(); - } else if (providerId === 'cloud') { + if (providerId === 'cloud') { const signedIn = await AccountService.getInstance(this.context).isSignedIn(); if (!signedIn) { return { ok: false, error: 'Sign in to NexQL Cloud first.' }; } } + const provider = this.createProvider(providerId); const test = await provider.testConnection(); return test.ok ? { ok: true } : { ok: false, error: test.error ?? 'Connection failed' }; } - async setupVault( - mode: 'create' | 'unlock', - secretKey?: string, - options?: { passphrase?: string; legacyEmail?: string }, - ): Promise<{ ok: boolean; secretKey?: string; generation?: string; error?: string }> { - const vault = VaultService.getInstance(this.context); - if (mode === 'create') { - const { secretKey: created, generation } = await vault.createVault({ - passphrase: options?.passphrase, - }); - return { ok: true, secretKey: created, generation }; - } - if (!secretKey) { - return { ok: false, error: 'Secret key required to unlock.' }; - } - try { - await vault.unlock(secretKey, options?.legacyEmail); - return { ok: true, generation: vault.getGeneration() }; - } catch (e) { - return { ok: false, error: e instanceof Error ? e.message : 'Unlock failed' }; - } - } - async completeSetup( providerId: SyncProviderId, - flags: { syncConnections: boolean; syncQueries: boolean; syncNotebooks: boolean; syncPasswords: boolean }, - vaultMode: 'create' | 'unlock', + flags: { syncConnections: boolean; syncQueries: boolean; syncNotebooks: boolean }, + postgresConnectionId?: string, ): Promise { if (!allowedSyncProviders().includes(providerId)) { const tier = syncProviderMinTier(providerId); return { ok: false, error: `Requires NexQL ${TIER_DISPLAY[tier]}.` }; } + if (providerId === 'postgres' && postgresConnectionId) { + await vscode.workspace + .getConfiguration() + .update('postgresExplorer.sync.postgresConnectionId', postgresConnectionId, vscode.ConfigurationTarget.Global); + } + await ensureDeviceName(this.context); const controller = SyncController.getInstance(); - const vault = VaultService.getInstance(this.context); - const gistId = providerId === 'gist' - ? await this.context.secrets.get('postgresExplorer.sync.gistId') - : undefined; - const accountEmail = await AccountService.getInstance(this.context).getAccountEmail(); await controller.saveConfig({ providerId, - gistId, syncConnections: flags.syncConnections, syncQueries: flags.syncQueries, syncNotebooks: flags.syncNotebooks, - syncPasswords: flags.syncPasswords, paused: false, accountEmail: accountEmail?.trim(), - vaultGeneration: vault.getGeneration(), }); - if (providerId === 'gist' && vaultMode === 'unlock') { - const provider = new GistSyncProvider(this.context); - await provider.linkToRemoteStorage({ mode: 'unlock', vaultGeneration: vault.getGeneration() }); - } - const result = await controller.runSync(); - if (providerId === 'cloud') { - try { - const { SharingService } = await import('./SharingService'); - await new SharingService(this.context).registerPublicKey(); - } catch { - /* best-effort */ - } - } - return { ok: true, - pushed: result?.pushed, - pulled: result?.pulled, + pushed: result && 'pushed' in result ? result.pushed : undefined, + pulled: result && 'pulled' in result ? result.pulled : undefined, }; } - async exportRecoveryKit(generation: string, secretKey: string, customPassphrase?: boolean): Promise { - const uri = await vscode.window.showSaveDialog({ - defaultUri: vscode.Uri.file('pgstudio-recovery-kit.txt'), - filters: { Text: ['txt'] }, - }); - if (uri) { - const secretLine = customPassphrase - ? 'Secret: (your custom passphrase — not stored here)' - : `Secret: ${secretKey}`; - await vscode.workspace.fs.writeFile( - uri, - Buffer.from( - `PgStudio Sync Recovery Kit\nVault ID: ${generation}\n${secretLine}\n\nKeep this safe. Without the secret, encrypted data cannot be recovered.`, - ), - ); - } - } - private createProvider(id: SyncProviderId) { switch (id) { - case 'gist': - return new GistSyncProvider(this.context); - case 'onedrive': - return new OneDriveSyncProvider(this.context); - case 'gdrive': - return new GoogleDriveSyncProvider(this.context); case 'cloud': return new CloudSyncProvider(this.context); case 'postgres': diff --git a/src/features/sync/VaultService.ts b/src/features/sync/VaultService.ts deleted file mode 100644 index d5adf70..0000000 --- a/src/features/sync/VaultService.ts +++ /dev/null @@ -1,238 +0,0 @@ -import * as crypto from 'crypto'; -import * as vscode from 'vscode'; -import { decodeEnvelope, encodeEnvelope } from './envelope'; -import { SCRYPT_N, SCRYPT_P, SCRYPT_R } from './constants'; -import { generateIdentityKeyPair, type IdentityKeyPair } from './shareCrypto'; -import type { VaultManifest } from './types'; - -const VAULT_KEY_SECRET = 'postgresExplorer.sync.vaultKey'; -const WRAPPED_VAULT_SECRET = 'postgresExplorer.sync.wrappedVaultKey'; -const VAULT_MANIFEST_SECRET = 'postgresExplorer.sync.vaultManifest'; -const IDENTITY_PUBLIC_SECRET = 'postgresExplorer.sync.identityPublicKey'; -const IDENTITY_PRIVATE_SECRET = 'postgresExplorer.sync.identityPrivateKey'; -const SECRET_KEY_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; -const VAULT_MANIFEST_VERSION = 2; - -export interface CreateVaultOptions { - /** User-chosen passphrase; auto-generated secret key when omitted. */ - passphrase?: string; -} - -/** Client-side encrypted vault: scrypt KEK → wrapped vault key (AES-256-GCM). */ -export class VaultService { - private static instance: VaultService; - private vaultKey: Buffer | null = null; - private manifest: VaultManifest | null = null; - - private constructor(private readonly context: vscode.ExtensionContext) {} - - static getInstance(context?: vscode.ExtensionContext): VaultService { - if (!VaultService.instance) { - if (!context) { - throw new Error('VaultService not initialized'); - } - VaultService.instance = new VaultService(context); - } - return VaultService.instance; - } - - static resetInstanceForTests(): void { - VaultService.instance = undefined as unknown as VaultService; - } - - isUnlocked(): boolean { - return this.vaultKey !== null; - } - - getGeneration(): string | undefined { - return this.manifest?.generation; - } - - /** @deprecated v1 vaults only — v2 vaults have no account email. */ - getAccountEmail(): string | undefined { - return this.manifest?.email; - } - - isLegacyManifest(manifest: VaultManifest): boolean { - return manifest.version !== VAULT_MANIFEST_VERSION && !!manifest.email; - } - - /** Normalize email for v1 scrypt salt. */ - static normalizeEmail(email: string): string { - return email.trim().toLowerCase(); - } - - /** v1 KEK: secret key + normalized email as salt. */ - static deriveKek(secretKey: string, email: string): Buffer { - const salt = Buffer.from(VaultService.normalizeEmail(email), 'utf8'); - return VaultService.scryptKek(secretKey, salt); - } - - /** v2 KEK: secret key + random salt from manifest. */ - static deriveKekFromSalt(secretKey: string, saltHex: string): Buffer { - return VaultService.scryptKek(secretKey, Buffer.from(saltHex, 'hex')); - } - - private static scryptKek(secretKey: string, salt: Buffer): Buffer { - return crypto.scryptSync(secretKey.trim().toUpperCase(), salt, 32, { - N: SCRYPT_N, - r: SCRYPT_R, - p: SCRYPT_P, - maxmem: 128 * 1024 * 1024, - }); - } - - private static resolveKek(secretKey: string, manifest: VaultManifest): Buffer { - if (manifest.version === VAULT_MANIFEST_VERSION || !manifest.email) { - return VaultService.deriveKekFromSalt(secretKey, manifest.salt); - } - return VaultService.deriveKek(secretKey, manifest.email); - } - - /** Generate ~26 char base32 secret key for one-time display. */ - static generateSecretKey(length = 26): string { - const bytes = crypto.randomBytes(length); - let result = ''; - for (let i = 0; i < length; i++) { - result += SECRET_KEY_CHARS[bytes[i] % SECRET_KEY_CHARS.length]; - } - return result; - } - - /** - * Create a new v2 vault. - * Auto-generates a secret key unless a custom passphrase is provided. - */ - async createVault(options?: CreateVaultOptions): Promise<{ secretKey: string; generation: string }> { - const vaultKey = crypto.randomBytes(32); - const customPassphrase = options?.passphrase?.trim(); - const secretKey = customPassphrase || VaultService.generateSecretKey(); - const generation = crypto.randomUUID(); - const salt = crypto.randomBytes(16).toString('hex'); - const kek = VaultService.deriveKekFromSalt(secretKey, salt); - - const wrapped = encodeEnvelope(vaultKey, kek); - this.manifest = { - version: VAULT_MANIFEST_VERSION, - generation, - wrappedVaultKey: wrapped.toString('base64'), - salt, - kdf: 'scrypt', - }; - this.vaultKey = vaultKey; - - await this.context.secrets.store(VAULT_KEY_SECRET, vaultKey.toString('base64')); - await this.context.secrets.store(WRAPPED_VAULT_SECRET, this.manifest.wrappedVaultKey); - await this.context.secrets.store(VAULT_MANIFEST_SECRET, JSON.stringify(this.manifest)); - - return { secretKey, generation }; - } - - /** Unlock vault with secret key; legacyEmail required for v1 vaults. */ - async unlock(secretKey: string, legacyEmail?: string): Promise { - const raw = await this.context.secrets.get(VAULT_MANIFEST_SECRET); - if (!raw) { - throw new Error('No vault found. Set up sync first.'); - } - this.manifest = JSON.parse(raw) as VaultManifest; - - if (this.isLegacyManifest(this.manifest) && !legacyEmail && !this.manifest.email) { - throw new Error('Account email is required to unlock this vault.'); - } - if (this.isLegacyManifest(this.manifest) && legacyEmail) { - this.manifest.email = VaultService.normalizeEmail(legacyEmail); - } - - const kek = VaultService.resolveKek(secretKey, this.manifest); - const wrapped = Buffer.from(this.manifest.wrappedVaultKey, 'base64'); - - try { - this.vaultKey = decodeEnvelope(wrapped, kek); - } catch { - throw new Error('Secret key is incorrect for this vault'); - } - - await this.context.secrets.store(VAULT_KEY_SECRET, this.vaultKey.toString('base64')); - } - - /** Load cached vault key from SecretStorage (post-unlock). */ - async tryLoadCachedKey(): Promise { - const cached = await this.context.secrets.get(VAULT_KEY_SECRET); - const raw = await this.context.secrets.get(VAULT_MANIFEST_SECRET); - if (!cached || !raw) { - return false; - } - this.manifest = JSON.parse(raw) as VaultManifest; - this.vaultKey = Buffer.from(cached, 'base64'); - return true; - } - - /** Stop if remote vault generation differs from local. */ - async checkGeneration(remoteGeneration: string): Promise<'ok' | 'mismatch'> { - if (!this.manifest) { - const raw = await this.context.secrets.get(VAULT_MANIFEST_SECRET); - if (raw) { - this.manifest = JSON.parse(raw) as VaultManifest; - } - } - if (!this.manifest?.generation) { - return 'ok'; - } - return this.manifest.generation === remoteGeneration ? 'ok' : 'mismatch'; - } - - getVaultKey(): Buffer { - if (!this.vaultKey) { - throw new Error('Vault is locked'); - } - return this.vaultKey; - } - - encrypt(plaintext: Buffer): Buffer { - return encodeEnvelope(plaintext, this.getVaultKey()); - } - - decrypt(blob: Buffer): Buffer { - return decodeEnvelope(blob, this.getVaultKey()); - } - - /** - * The vault's X25519 identity keypair for team sharing. Lazily generated on - * first use (so existing vaults gain a keypair on next unlock). The private - * key is stored encrypted with the vault key; the public key is plaintext. - */ - async getIdentityKeyPair(): Promise { - const pub = await this.context.secrets.get(IDENTITY_PUBLIC_SECRET); - const wrappedPriv = await this.context.secrets.get(IDENTITY_PRIVATE_SECRET); - if (pub && wrappedPriv) { - const priv = this.decrypt(Buffer.from(wrappedPriv, 'base64')).toString('base64'); - return { publicKey: pub, privateKey: priv }; - } - const pair = generateIdentityKeyPair(); - await this.context.secrets.store(IDENTITY_PUBLIC_SECRET, pair.publicKey); - await this.context.secrets.store( - IDENTITY_PRIVATE_SECRET, - this.encrypt(Buffer.from(pair.privateKey, 'base64')).toString('base64'), - ); - return pair; - } - - async getIdentityPublicKey(): Promise { - return (await this.getIdentityKeyPair()).publicKey; - } - - async getWrappedManifestForUpload(): Promise { - const raw = await this.context.secrets.get(VAULT_MANIFEST_SECRET); - return raw ? (JSON.parse(raw) as VaultManifest) : null; - } - - async signOut(): Promise { - this.vaultKey = null; - this.manifest = null; - await this.context.secrets.delete(VAULT_KEY_SECRET); - await this.context.secrets.delete(WRAPPED_VAULT_SECRET); - await this.context.secrets.delete(VAULT_MANIFEST_SECRET); - await this.context.secrets.delete(IDENTITY_PUBLIC_SECRET); - await this.context.secrets.delete(IDENTITY_PRIVATE_SECRET); - } -} diff --git a/src/features/sync/WorkspaceSharingService.ts b/src/features/sync/WorkspaceSharingService.ts new file mode 100644 index 0000000..edee3b4 --- /dev/null +++ b/src/features/sync/WorkspaceSharingService.ts @@ -0,0 +1,116 @@ +import * as vscode from 'vscode'; +import { AccountService } from './AccountService'; +import { getDeviceName, getOrCreateDeviceId } from './deviceId'; +import { httpRequest } from './providers/httpUtils'; +import { DEFAULT_SYNC_API_ENDPOINT } from './constants'; +import type { WorkspaceMemberView, WorkspaceRole, WorkspaceView } from './types'; + +/** + * Team workspaces (server-ACL sharing — pass 1, no client crypto). + * + * A shared workspace is a sync space with a member roster. Items are shared by + * syncing them into the workspace's space; the server enforces who may read or + * write. O(1) to add a member, no per-item re-encryption. + */ +export class WorkspaceSharingService { + constructor(private readonly context: vscode.ExtensionContext) {} + + private baseUrl(): string { + const configured = vscode.workspace.getConfiguration().get('postgresExplorer.sync.apiEndpoint'); + return configured?.trim() || DEFAULT_SYNC_API_ENDPOINT; + } + + private async headers(): Promise> { + let token = await AccountService.getInstance(this.context).getAccessToken(); + if (!token) { + token = await AccountService.getInstance(this.context).refreshAccessToken(); + } + if (!token) { + throw new Error('Sign in to NexQL Cloud first.'); + } + const headers: Record = { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + 'X-Device-Id': getOrCreateDeviceId(this.context), + }; + const name = getDeviceName(this.context); + if (name) { + headers['X-Device-Name'] = name; + } + return headers; + } + + async listWorkspaces(): Promise { + const res = await httpRequest(`${this.baseUrl()}/sync/v2/spaces`, { headers: await this.headers() }); + if (res.statusCode >= 400) { + return []; + } + const data = JSON.parse(res.body.toString()) as { + spaces: Array<{ space_id: string; name: string; owner_email: string; role: WorkspaceRole }>; + }; + return (data.spaces ?? []).map((s) => ({ + spaceId: s.space_id, + name: s.name, + ownerEmail: s.owner_email, + role: s.role, + })); + } + + async createWorkspace(name: string): Promise { + const res = await httpRequest(`${this.baseUrl()}/sync/v2/spaces`, { + method: 'POST', + headers: await this.headers(), + body: JSON.stringify({ action: 'create', name }), + }); + if (res.statusCode >= 400) { + throw new Error(this.errorOf(res.body, 'Failed to create workspace')); + } + const data = JSON.parse(res.body.toString()) as { space_id: string; name: string }; + const email = (await AccountService.getInstance(this.context).getAccountEmail()) ?? ''; + return { spaceId: data.space_id, name: data.name, ownerEmail: email, role: 'owner' }; + } + + async listMembers(spaceId: string): Promise { + const res = await httpRequest( + `${this.baseUrl()}/sync/v2/spaces?space=${encodeURIComponent(spaceId)}`, + { headers: await this.headers() }, + ); + if (res.statusCode >= 400) { + return []; + } + const data = JSON.parse(res.body.toString()) as { + members: Array<{ email: string; role: WorkspaceRole; added_at: string }>; + }; + return (data.members ?? []).map((m) => ({ email: m.email, role: m.role, addedAt: m.added_at })); + } + + async addMember(spaceId: string, email: string, role: 'editor' | 'viewer'): Promise { + const res = await httpRequest(`${this.baseUrl()}/sync/v2/spaces`, { + method: 'POST', + headers: await this.headers(), + body: JSON.stringify({ action: 'addMember', space: spaceId, email, role }), + }); + if (res.statusCode >= 400) { + throw new Error(this.errorOf(res.body, 'Failed to add member')); + } + } + + async removeMember(spaceId: string, email: string): Promise { + const res = await httpRequest(`${this.baseUrl()}/sync/v2/spaces`, { + method: 'POST', + headers: await this.headers(), + body: JSON.stringify({ action: 'removeMember', space: spaceId, email }), + }); + if (res.statusCode >= 400) { + throw new Error(this.errorOf(res.body, 'Failed to remove member')); + } + } + + private errorOf(body: Buffer, fallback: string): string { + try { + return (JSON.parse(body.toString()) as { error?: string }).error ?? fallback; + } catch { + return fallback; + } + } +} diff --git a/src/features/sync/constants.ts b/src/features/sync/constants.ts index 67cfa0d..5673265 100644 --- a/src/features/sync/constants.ts +++ b/src/features/sync/constants.ts @@ -1,4 +1,6 @@ export const SYNC_BASE_MANIFEST_KEY = 'postgres-explorer.sync.baseManifest'; +/** Last server cursor confirmed for the active space (git-like delta sync). */ +export const SYNC_CURSOR_KEY = 'postgres-explorer.sync.cursor'; export const SYNC_DEVICE_ID_KEY = 'postgres-explorer.sync.deviceId'; export const SYNC_CONFIG_KEY = 'postgres-explorer.sync.config'; export const SYNC_PATH_OVERRIDES_KEY = 'postgres-explorer.sync.pathOverrides'; @@ -23,6 +25,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/migrations/pgstudio_sync_down.sql b/src/features/sync/migrations/pgstudio_sync_down.sql deleted file mode 100644 index 930dced..0000000 --- a/src/features/sync/migrations/pgstudio_sync_down.sql +++ /dev/null @@ -1,5 +0,0 @@ --- Down: pgstudio_sync schema -DROP INDEX IF EXISTS pgstudio_sync.sync_items_pull_idx; -DROP TABLE IF EXISTS pgstudio_sync.sync_meta; -DROP TABLE IF EXISTS pgstudio_sync.sync_items; -DROP SCHEMA IF EXISTS pgstudio_sync; diff --git a/src/features/sync/migrations/pgstudio_sync_up.sql b/src/features/sync/migrations/pgstudio_sync_up.sql deleted file mode 100644 index 77c4e13..0000000 --- a/src/features/sync/migrations/pgstudio_sync_up.sql +++ /dev/null @@ -1,24 +0,0 @@ --- Up: pgstudio_sync schema for optional team Postgres sync backend (Phase 5) -CREATE SCHEMA IF NOT EXISTS pgstudio_sync; - -CREATE TABLE IF NOT EXISTS pgstudio_sync.sync_items ( - account_id TEXT NOT NULL, - item_id TEXT NOT NULL, - kind TEXT NOT NULL CHECK (kind IN ('connection','query','notebook','secrets')), - blob BYTEA NOT NULL, - content_hash TEXT NOT NULL, - revision INT NOT NULL DEFAULT 1, - device_id TEXT NOT NULL, - deleted BOOLEAN NOT NULL DEFAULT false, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (account_id, item_id) -); - -CREATE INDEX IF NOT EXISTS sync_items_pull_idx ON pgstudio_sync.sync_items (account_id, updated_at); - -CREATE TABLE IF NOT EXISTS pgstudio_sync.sync_meta ( - account_id TEXT PRIMARY KEY, - bound_device_id TEXT, - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -); diff --git a/src/features/sync/migrations/pgstudio_sync_v2_down.sql b/src/features/sync/migrations/pgstudio_sync_v2_down.sql new file mode 100644 index 0000000..06c6dce --- /dev/null +++ b/src/features/sync/migrations/pgstudio_sync_v2_down.sql @@ -0,0 +1,6 @@ +-- Tear down PgStudio self-hosted sync schema (v2). Destroys all synced data. +DROP TABLE IF EXISTS pgstudio_sync.items_v2; +DROP TABLE IF EXISTS pgstudio_sync.deletes_v2; +DROP SEQUENCE IF EXISTS pgstudio_sync.cursor_seq; +-- Schema kept (may hold workspace tables); drop manually if unused: +-- DROP SCHEMA IF EXISTS pgstudio_sync CASCADE; diff --git a/src/features/sync/migrations/pgstudio_sync_v2_up.sql b/src/features/sync/migrations/pgstudio_sync_v2_up.sql new file mode 100644 index 0000000..8b0d639 --- /dev/null +++ b/src/features/sync/migrations/pgstudio_sync_v2_up.sql @@ -0,0 +1,33 @@ +-- PgStudio self-hosted sync schema (v2 — git-like). +-- Run once against the Postgres database you point sync at. The extension also +-- creates these objects automatically on first sync; this script is for manual +-- / least-privilege setups. + +CREATE SCHEMA IF NOT EXISTS pgstudio_sync; + +CREATE SEQUENCE IF NOT EXISTS pgstudio_sync.cursor_seq; + +-- Current items. Each write stamps a monotonic `version` from cursor_seq. +CREATE TABLE IF NOT EXISTS pgstudio_sync.items_v2 ( + space_id TEXT NOT NULL, + item_id TEXT NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('connection','query','notebook')), + blob BYTEA NOT NULL, + content_hash TEXT NOT NULL, + version BIGINT NOT NULL, + device_id TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (space_id, item_id) +); +CREATE INDEX IF NOT EXISTS items_v2_cursor_idx ON pgstudio_sync.items_v2 (space_id, version); + +-- Permanent delete log — never pruned. Stops deleted items resurrecting. +CREATE TABLE IF NOT EXISTS pgstudio_sync.deletes_v2 ( + space_id TEXT NOT NULL, + item_id TEXT NOT NULL, + version BIGINT NOT NULL, + deleted_by TEXT NOT NULL, + deleted_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (space_id, item_id) +); +CREATE INDEX IF NOT EXISTS deletes_v2_cursor_idx ON pgstudio_sync.deletes_v2 (space_id, version); diff --git a/src/features/sync/providers/CloudSyncProvider.ts b/src/features/sync/providers/CloudSyncProvider.ts index 2c04cef..fdb5ef4 100644 --- a/src/features/sync/providers/CloudSyncProvider.ts +++ b/src/features/sync/providers/CloudSyncProvider.ts @@ -1,29 +1,43 @@ import * as vscode from 'vscode'; import type { CloudQuotaView, - CloudSyncManifestEntry, + PushResult, + SyncDelta, SyncDeviceView, - SyncProvider, - SyncPushItem, - SyncSnapshot, - SyncItemMeta, + SyncOp, + SyncProviderV2, } from '../types'; import { AccountService } from '../AccountService'; import { getDeviceName, getOrCreateDeviceId } from '../deviceId'; import { httpRequest } from './httpUtils'; import { DEFAULT_SYNC_API_ENDPOINT } from '../constants'; -/** NexQL Cloud sync backend (nexql.astrx.dev) — Sponsor+ tier. */ -export class CloudSyncProvider implements SyncProvider { +interface RawDelta { + cursor: number; + upserts: Array<{ + item_id: string; + kind: 'connection' | 'query' | 'notebook'; + content_hash: string; + version: number; + device_id: string; + blob: string; + updated_at: string; + }>; + deletes: string[]; +} + +/** NexQL Cloud sync backend (nexql.astrx.dev) — git-like v2 protocol. */ +export class CloudSyncProvider implements SyncProviderV2 { readonly id = 'cloud' as const; - constructor(private readonly context: vscode.ExtensionContext) {} + constructor( + private readonly context: vscode.ExtensionContext, + private readonly spaceId?: string, + ) {} private baseUrl(): string { - const configured = vscode.workspace - .getConfiguration() - .get('postgresExplorer.sync.apiEndpoint'); - return (configured?.trim()) || DEFAULT_SYNC_API_ENDPOINT; + const configured = vscode.workspace.getConfiguration().get('postgresExplorer.sync.apiEndpoint'); + return configured?.trim() || DEFAULT_SYNC_API_ENDPOINT; } private async authHeaders(): Promise> { @@ -35,8 +49,7 @@ export class CloudSyncProvider implements SyncProvider { throw new Error('Not signed in to NexQL account'); } const headers: Record = { Authorization: `Bearer ${token}` }; - const deviceId = getOrCreateDeviceId(this.context); - headers['X-Device-Id'] = deviceId; + headers['X-Device-Id'] = getOrCreateDeviceId(this.context); const deviceName = getDeviceName(this.context); if (deviceName) { headers['X-Device-Name'] = deviceName; @@ -47,84 +60,90 @@ export class CloudSyncProvider implements SyncProvider { async testConnection(): Promise<{ ok: boolean; account?: string; error?: string }> { try { const headers = await this.authHeaders(); - const res = await httpRequest(`${this.baseUrl()}/sync/manifest`, { headers }); - if (res.statusCode === 404) { - const email = await AccountService.getInstance().getAccountEmail(); - return { ok: true, account: email }; - } + const res = await httpRequest(`${this.baseUrl()}/sync/v2/pull?since=0${this.spaceQuery()}`, { headers }); if (res.statusCode >= 400) { return { ok: false, error: `API ${res.statusCode}` }; } - const email = await AccountService.getInstance().getAccountEmail(); - return { ok: true, account: email }; + return { ok: true, account: await AccountService.getInstance().getAccountEmail() }; } catch (e) { return { ok: false, error: e instanceof Error ? e.message : String(e) }; } } - async pull(sinceRevision?: number): Promise { - const headers = await this.authHeaders(); - const since = sinceRevision ? `?since=${sinceRevision}` : ''; - const res = await httpRequest(`${this.baseUrl()}/sync/manifest${since}`, { headers }); + private spaceQuery(): string { + return this.spaceId ? `&space=${encodeURIComponent(this.spaceId)}` : ''; + } - if (res.statusCode === 404) { - return { manifest: [], getBlob: async () => undefined }; + async pullDelta(since: number): Promise { + const headers = await this.authHeaders(); + const res = await httpRequest( + `${this.baseUrl()}/sync/v2/pull?since=${since}${this.spaceQuery()}`, + { headers }, + ); + if (res.statusCode >= 400) { + throw new Error(`Pull failed: API ${res.statusCode}`); } - - const entries = JSON.parse(res.body.toString()) as CloudSyncManifestEntry[]; - const manifest: SyncItemMeta[] = entries.map((e) => ({ - id: e.item_id, - kind: e.kind, - contentHash: e.content_hash, - revision: e.revision, - updatedAt: new Date(e.updated_at).getTime(), - deviceId: e.device_id, - deleted: e.deleted, - })); - + const raw = JSON.parse(res.body.toString()) as RawDelta; return { - manifest, - getBlob: async (id: string) => { - const blobRes = await httpRequest(`${this.baseUrl()}/sync/items/${encodeURIComponent(id)}`, { headers }); - if (blobRes.statusCode === 404) { - return undefined; - } - return blobRes.body; - }, + cursor: Number(raw.cursor) || 0, + upserts: raw.upserts.map((u) => ({ + meta: { + id: u.item_id, + kind: u.kind, + contentHash: u.content_hash, + version: Number(u.version), + deviceId: u.device_id, + updatedAt: new Date(u.updated_at).getTime(), + }, + blob: Buffer.from(u.blob, 'base64'), + })), + deletes: raw.deletes ?? [], }; } - async push(items: SyncPushItem[], options?: import('../types').SyncPushOptions): Promise { - const headers = { - ...(await this.authHeaders()), - 'Content-Type': 'application/json', - }; - - for (const item of items) { - const payload = JSON.stringify({ - kind: item.meta.kind, - content_hash: item.meta.contentHash, - revision: item.meta.revision, - device_id: item.meta.deviceId, - deleted: item.meta.deleted, - blob: item.blob.toString('base64'), - }); - - await httpRequest(`${this.baseUrl()}/sync/items/${encodeURIComponent(item.meta.id)}`, { - method: 'PUT', - headers, - body: payload, - }); + async pushBatch(ops: SyncOp[]): Promise { + const headers = { ...(await this.authHeaders()), 'Content-Type': 'application/json' }; + const body = JSON.stringify({ + space: this.spaceId, + ops: ops.map((op) => ({ + op: op.op, + item_id: op.itemId, + kind: op.kind, + base_version: op.baseVersion, + content_hash: op.contentHash, + blob: op.blob ? op.blob.toString('base64') : undefined, + })), + }); + const res = await httpRequest(`${this.baseUrl()}/sync/v2/push`, { method: 'POST', headers, body }); + if (res.statusCode >= 400) { + throw new Error(`Push failed: API ${res.statusCode}`); } + const raw = JSON.parse(res.body.toString()) as { + cursor: number; + accepted: Array<{ item_id: string; version: number }>; + rejected: Array<{ item_id: string; remote_version: number | null; remote_hash: string | null }>; + }; + return { + cursor: Number(raw.cursor) || 0, + accepted: raw.accepted.map((a) => ({ itemId: a.item_id, version: Number(a.version) })), + rejected: raw.rejected.map((r) => ({ + itemId: r.item_id, + remoteVersion: r.remote_version == null ? null : Number(r.remote_version), + remoteHash: r.remote_hash, + })), + }; + } - const { publishableManifest } = await import('../syncManifest'); - const manifest = options?.manifest ?? items.map((i) => i.meta); - const remoteManifest = options?.manifest ? publishableManifest(manifest) : manifest; - await httpRequest(`${this.baseUrl()}/sync/manifest`, { - method: 'PUT', + async resetSpace(): Promise { + const headers = { ...(await this.authHeaders()), 'Content-Type': 'application/json' }; + const res = await httpRequest(`${this.baseUrl()}/sync/v2/reset`, { + method: 'POST', headers, - body: JSON.stringify(remoteManifest), + body: JSON.stringify({ space: this.spaceId }), }); + if (res.statusCode >= 400) { + throw new Error(`Reset failed: API ${res.statusCode}`); + } } async getQuota(): Promise { @@ -173,10 +192,10 @@ export class CloudSyncProvider implements SyncProvider { async revokeDevice(deviceId: string): Promise { const headers = await this.authHeaders(); - const res = await httpRequest( - `${this.baseUrl()}/sync/devices/${encodeURIComponent(deviceId)}`, - { method: 'DELETE', headers }, - ); + const res = await httpRequest(`${this.baseUrl()}/sync/devices/${encodeURIComponent(deviceId)}`, { + method: 'DELETE', + headers, + }); return res.statusCode === 204; } } diff --git a/src/features/sync/providers/GistSyncProvider.ts b/src/features/sync/providers/GistSyncProvider.ts deleted file mode 100644 index cd8c54d..0000000 --- a/src/features/sync/providers/GistSyncProvider.ts +++ /dev/null @@ -1,507 +0,0 @@ -import * as vscode from 'vscode'; -import type { SyncProvider, SyncPushItem, SyncPushOptions, SyncSnapshot, SyncItemMeta } from '../types'; -import { - activeManifestIds, - parseSyncBlobId, - publishableManifest, - resolvePushManifest, - syncBlobName, -} from '../syncManifest'; -import { getGithubToken, githubDeviceFlowSignIn } from '../auth/githubDeviceFlow'; -import { httpRequest } from './httpUtils'; -import { GIST_DESCRIPTION, GIST_MAX_FILE_BYTES, GIST_META_FILE, SYNC_CONFIG_KEY } from '../constants'; -import type { SyncConfig } from '../types'; -import { VaultService } from '../VaultService'; - -const GIST_MANIFEST_FILE = 'manifest.json'; -const GIST_ID_KEY = 'postgresExplorer.sync.gistId'; - -export interface GistCandidate { - id: string; - description: string; - updatedAt: string; - generation?: string; - itemCount: number; -} - -export interface GistLinkOptions { - mode: 'unlock' | 'create'; - vaultGeneration?: string; -} - -interface GithubGistSummary { - id: string; - description: string | null; - updated_at: string; - files: Record; -} - -/** - * Private GitHub Gist backend — zero registration when vscode GitHub auth is available. - * Fallback: GitHub OAuth device flow (client id from settings). - */ -export class GistSyncProvider implements SyncProvider { - readonly id = 'gist' as const; - - constructor(private readonly context: vscode.ExtensionContext) {} - - static async clearStoredGistId(context: vscode.ExtensionContext): Promise { - await context.secrets.delete(GIST_ID_KEY); - } - - private clientId(): string { - return vscode.workspace - .getConfiguration() - .get('postgresExplorer.sync.githubClientId', 'Ov23liPLACEHOLDER_GITHUB_CLIENT'); - } - - private authHeaders(token: string): Record { - return { - Authorization: `Bearer ${token}`, - Accept: 'application/vnd.github+json', - 'User-Agent': 'PgStudio-Sync', - }; - } - - async ensureAuth(): Promise { - let token = await getGithubToken(this.context); - if (!token) { - try { - const session = await vscode.authentication.getSession('github', ['gist'], { createIfNone: true }); - token = session?.accessToken; - } catch { - // GitHub auth unavailable in this editor fork - } - } - if (!token) { - const res = await githubDeviceFlowSignIn(this.context, this.clientId()); - token = res.token; - } - return token; - } - - async testConnection(): Promise<{ ok: boolean; account?: string; error?: string }> { - try { - const token = await this.ensureAuth(); - const res = await httpRequest('https://api.github.com/user', { - headers: this.authHeaders(token), - }); - if (res.statusCode >= 400) { - return { ok: false, error: `GitHub API ${res.statusCode}` }; - } - const user = JSON.parse(res.body.toString()); - return { ok: true, account: user.login }; - } catch (e) { - return { ok: false, error: e instanceof Error ? e.message : String(e) }; - } - } - - /** Bind this editor to an existing PgStudio gist (second device / repair). */ - async linkToRemoteStorage(options: GistLinkOptions): Promise { - const token = await this.ensureAuth(); - const stored = await this.getStoredGistId(); - if (stored && await this.validateGistId(token, stored)) { - return true; - } - - const candidates = await this.listPgStudioGists(token); - - if (options.mode === 'unlock') { - return this.linkOnUnlock(token, candidates, options.vaultGeneration); - } - - const sameVault = options.vaultGeneration - ? candidates.filter((c) => c.generation === options.vaultGeneration) - : []; - if (sameVault.length > 0) { - const choice = await vscode.window.showWarningMessage( - 'An existing PgStudio sync gist matches this vault. Link to it instead of creating a duplicate?', - 'Link Existing', - 'Create New Gist', - ); - if (choice === 'Link Existing') { - const picked = sameVault.length === 1 - ? sameVault[0] - : await this.pickGistCandidate(sameVault); - if (!picked) { - return false; - } - await this.storeGistId(picked.id); - return true; - } - } - - return true; - } - - /** Manual repair — pick or paste an existing gist id. */ - async linkExistingGistInteractive(): Promise { - const token = await this.ensureAuth(); - const candidates = await this.listPgStudioGists(token); - if (candidates.length > 0) { - const picked = await this.pickGistCandidate(candidates); - if (picked) { - await this.storeGistId(picked.id); - return true; - } - } - - const manual = await vscode.window.showInputBox({ - title: 'Link GitHub Gist', - prompt: 'Paste the gist ID or URL from github.com/gist/…', - ignoreFocusOut: true, - }); - if (!manual?.trim()) { - return false; - } - const gistId = this.parseGistId(manual.trim()); - if (!gistId || !(await this.validateGistId(token, gistId))) { - await vscode.window.showErrorMessage('Gist not found or not a PgStudio sync vault.'); - return false; - } - await this.storeGistId(gistId); - return true; - } - - async pull(_sinceRevision?: number): Promise { - const token = await this.ensureAuth(); - const gistId = await this.resolveGistId(token); - if (!gistId) { - return { manifest: [], getBlob: async () => undefined }; - } - - const res = await httpRequest(`https://api.github.com/gists/${gistId}`, { - headers: this.authHeaders(token), - }); - - if (res.statusCode === 404) { - return { manifest: [], getBlob: async () => undefined }; - } - - const gist = JSON.parse(res.body.toString()) as { - files: Record; - }; - - const manifestRaw = gist.files[GIST_MANIFEST_FILE]?.content ?? '[]'; - const manifest = JSON.parse(manifestRaw) as SyncItemMeta[]; - const files = gist.files; - - return { - manifest, - getBlob: async (id: string) => { - const file = files[`item-${id}.bin`]; - if (!file?.content) { - return undefined; - } - return Buffer.from(file.content, 'base64'); - }, - }; - } - - async push(items: SyncPushItem[], options?: SyncPushOptions): Promise { - const token = await this.ensureAuth(); - const oversized = items.filter((i) => i.blob.length > GIST_MAX_FILE_BYTES); - if (oversized.length > 0) { - throw new OversizedItemError(oversized.map((i) => i.meta.id)); - } - - const snapshot = await this.pull(); - const manifest = resolvePushManifest(snapshot.manifest, items, options); - const remoteManifest = options?.manifest ? publishableManifest(manifest) : manifest; - const activeIds = activeManifestIds(manifest); - - const files: Record = { - [GIST_MANIFEST_FILE]: { content: JSON.stringify(remoteManifest, null, 2) }, - }; - for (const item of items) { - if (item.meta.deleted) { - files[syncBlobName(item.meta.id)] = null; - continue; - } - files[syncBlobName(item.meta.id)] = { content: item.blob.toString('base64') }; - } - for (const meta of manifest) { - if (meta.deleted) { - files[syncBlobName(meta.id)] = null; - } - } - - const gistIdForCleanup = await this.resolveGistId(token); - if (gistIdForCleanup) { - const gistRes = await httpRequest(`https://api.github.com/gists/${gistIdForCleanup}`, { - headers: this.authHeaders(token), - }); - if (gistRes.statusCode < 400) { - const gist = JSON.parse(gistRes.body.toString()) as { files: Record }; - for (const filename of Object.keys(gist.files)) { - const itemId = parseSyncBlobId(filename); - if (itemId && !activeIds.has(itemId)) { - files[filename] = null; - } - } - } - } - - const generation = VaultService.getInstance().getGeneration(); - if (generation) { - files[GIST_META_FILE] = { - content: JSON.stringify({ generation, version: 1 }, null, 2), - }; - } - - let gistId = await this.getStoredGistId(); - if (!gistId) { - const createRes = await httpRequest('https://api.github.com/gists', { - method: 'POST', - headers: { - ...this.authHeaders(token), - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - description: GIST_DESCRIPTION, - public: false, - files, - }), - }); - if (createRes.statusCode >= 400) { - throw new Error(`GitHub gist create failed: ${createRes.statusCode}`); - } - const created = JSON.parse(createRes.body.toString()); - gistId = created.id; - await this.storeGistId(gistId!); - return; - } - - await httpRequest(`https://api.github.com/gists/${gistId}`, { - method: 'PATCH', - headers: { - ...this.authHeaders(token), - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ files }), - }); - } - - private async linkOnUnlock( - token: string, - candidates: GistCandidate[], - vaultGeneration?: string, - ): Promise { - let matches = candidates; - if (vaultGeneration) { - const byGen = candidates.filter((c) => c.generation === vaultGeneration); - if (byGen.length > 0) { - matches = byGen; - } - } - - if (matches.length === 1) { - await this.storeGistId(matches[0].id); - vscode.window.showInformationMessage(`Linked to existing sync gist (${matches[0].itemCount} items).`); - return true; - } - - if (matches.length > 1) { - const picked = await this.pickGistCandidate(matches); - if (!picked) { - return false; - } - await this.storeGistId(picked.id); - return true; - } - - if (candidates.length > 0) { - const picked = await vscode.window.showQuickPick( - [ - { label: 'Pick an existing PgStudio gist', id: 'pick' }, - { label: 'Paste gist ID or URL', id: 'paste' }, - ], - { title: 'No gist matched your vault — link manually' }, - ); - if (!picked) { - return false; - } - if (picked.id === 'pick') { - const gist = await this.pickGistCandidate(candidates); - if (!gist) { - return false; - } - await this.storeGistId(gist.id); - return true; - } - } - - const manual = await vscode.window.showInputBox({ - title: 'Link existing sync gist', - prompt: 'Paste the gist ID from your first machine (github.com/gist/…)', - ignoreFocusOut: true, - }); - if (!manual?.trim()) { - return false; - } - const gistId = this.parseGistId(manual.trim()); - if (!gistId || !(await this.validateGistId(token, gistId))) { - await vscode.window.showErrorMessage('Gist not found or inaccessible with your GitHub account.'); - return false; - } - await this.storeGistId(gistId); - return true; - } - - private async pickGistCandidate(candidates: GistCandidate[]): Promise { - const items = candidates.map((c) => ({ - label: c.description || GIST_DESCRIPTION, - description: `${c.itemCount} items · updated ${c.updatedAt}${c.generation ? ` · vault ${c.generation.slice(0, 8)}…` : ''}`, - candidate: c, - })); - const picked = await vscode.window.showQuickPick(items, { - title: 'Select PgStudio sync gist', - placeHolder: 'Choose the gist created on your other machine', - }); - return picked?.candidate; - } - - private async listPgStudioGists(token: string): Promise { - const res = await httpRequest('https://api.github.com/gists?per_page=100', { - headers: this.authHeaders(token), - }); - if (res.statusCode >= 400) { - throw new Error(`GitHub API ${res.statusCode}`); - } - - const gists = JSON.parse(res.body.toString()) as GithubGistSummary[]; - const candidates: GistCandidate[] = []; - - for (const gist of gists) { - const hasManifest = GIST_MANIFEST_FILE in gist.files; - const isPgStudio = gist.description === GIST_DESCRIPTION || hasManifest; - if (!isPgStudio) { - continue; - } - - let generation: string | undefined; - let itemCount = 0; - try { - const detail = await this.fetchGistDetail(token, gist.id); - generation = detail.generation; - itemCount = detail.itemCount; - } catch { - itemCount = hasManifest ? 0 : 0; - } - - candidates.push({ - id: gist.id, - description: gist.description ?? GIST_DESCRIPTION, - updatedAt: gist.updated_at, - generation, - itemCount, - }); - } - - return candidates.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); - } - - private async fetchGistDetail( - token: string, - gistId: string, - ): Promise<{ generation?: string; itemCount: number }> { - const res = await httpRequest(`https://api.github.com/gists/${gistId}`, { - headers: this.authHeaders(token), - }); - if (res.statusCode >= 400) { - throw new Error(`GitHub API ${res.statusCode}`); - } - const gist = JSON.parse(res.body.toString()) as { - files: Record; - }; - let generation: string | undefined; - const metaRaw = gist.files[GIST_META_FILE]?.content; - if (metaRaw) { - try { - generation = JSON.parse(metaRaw).generation; - } catch { - /* ignore */ - } - } - const manifestRaw = gist.files[GIST_MANIFEST_FILE]?.content ?? '[]'; - let itemCount = 0; - try { - itemCount = (JSON.parse(manifestRaw) as unknown[]).length; - } catch { - itemCount = 0; - } - return { generation, itemCount }; - } - - private async validateGistId(token: string, gistId: string): Promise { - const res = await httpRequest(`https://api.github.com/gists/${gistId}`, { - headers: this.authHeaders(token), - }); - return res.statusCode === 200; - } - - private parseGistId(input: string): string | undefined { - const trimmed = input.trim(); - const urlMatch = trimmed.match(/gist\.github\.com\/(?:[^/]+\/)?([a-f0-9]+)/i); - if (urlMatch) { - return urlMatch[1]; - } - if (/^[a-f0-9]{8,}$/i.test(trimmed)) { - return trimmed; - } - return undefined; - } - - private async getStoredGistId(): Promise { - return this.context.secrets.get(GIST_ID_KEY); - } - - private async storeGistId(gistId: string): Promise { - await this.context.secrets.store(GIST_ID_KEY, gistId); - const config = this.context.globalState.get(SYNC_CONFIG_KEY, { - syncConnections: true, - syncQueries: true, - syncNotebooks: true, - syncPasswords: false, - paused: false, - }); - if (config.providerId === 'gist') { - await this.context.globalState.update(SYNC_CONFIG_KEY, { ...config, gistId }); - } - } - - /** Use stored id, or auto-discover a single matching gist for this vault. */ - private async resolveGistId(token: string): Promise { - const stored = await this.getStoredGistId(); - if (stored) { - return stored; - } - - const config = this.context.globalState.get(SYNC_CONFIG_KEY, { - syncConnections: true, - syncQueries: true, - syncNotebooks: true, - syncPasswords: false, - paused: false, - }); - if (!config.vaultGeneration) { - return undefined; - } - - const candidates = await this.listPgStudioGists(token); - const matches = candidates.filter((c) => c.generation === config.vaultGeneration); - if (matches.length === 1) { - await this.storeGistId(matches[0].id); - return matches[0].id; - } - - return undefined; - } -} - -export class OversizedItemError extends Error { - constructor(public readonly itemIds: string[]) { - super(`Oversized sync items: ${itemIds.join(', ')}`); - this.name = 'OversizedItemError'; - } -} diff --git a/src/features/sync/providers/GoogleDriveSyncProvider.ts b/src/features/sync/providers/GoogleDriveSyncProvider.ts deleted file mode 100644 index 4e7a1db..0000000 --- a/src/features/sync/providers/GoogleDriveSyncProvider.ts +++ /dev/null @@ -1,131 +0,0 @@ -import * as vscode from 'vscode'; -import type { SyncProvider, SyncPushItem, SyncPushOptions, SyncSnapshot, SyncItemMeta } from '../types'; -import { publishableManifest, resolvePushManifest } from '../syncManifest'; -import { getGDriveToken, googleLoopbackPkceSignIn } from '../auth/googleLoopbackPkce'; -import { httpRequest } from './httpUtils'; - -const APPDATA_FOLDER = 'pgstudio-sync'; - -/** Google Drive appdata backend (drive.appdata scope). */ -export class GoogleDriveSyncProvider implements SyncProvider { - readonly id = 'gdrive' as const; - - constructor(private readonly context: vscode.ExtensionContext) {} - - private clientId(): string { - // Placeholder — requires Google OAuth verification for production. - return vscode.workspace - .getConfiguration() - .get('postgresExplorer.sync.googleClientId', '000000000000-placeholder.apps.googleusercontent.com'); - } - - async ensureAuth(): Promise { - let token = await getGDriveToken(this.context); - if (!token) { - const res = await googleLoopbackPkceSignIn(this.context, this.clientId()); - token = res.token; - } - return token; - } - - async testConnection(): Promise<{ ok: boolean; account?: string; error?: string }> { - try { - const token = await this.ensureAuth(); - const res = await httpRequest('https://www.googleapis.com/drive/v3/about?fields=user', { - headers: { Authorization: `Bearer ${token}` }, - }); - if (res.statusCode >= 400) { - return { ok: false, error: `Drive API ${res.statusCode}` }; - } - const about = JSON.parse(res.body.toString()); - return { ok: true, account: about.user?.emailAddress ?? 'google-user' }; - } catch (e) { - return { ok: false, error: e instanceof Error ? e.message : String(e) }; - } - } - - async pull(_sinceRevision?: number): Promise { - const token = await this.ensureAuth(); - const manifestFile = await this.findFile(token, 'manifest.json'); - if (!manifestFile) { - return { manifest: [], getBlob: async () => undefined }; - } - - const content = await this.downloadFile(token, manifestFile); - const manifest = JSON.parse(content.toString()) as SyncItemMeta[]; - - return { - manifest, - getBlob: async (id: string) => { - const file = await this.findFile(token, `item-${id}.bin`); - if (!file) { - return undefined; - } - return this.downloadFile(token, file); - }, - }; - } - - async push(items: SyncPushItem[], options?: SyncPushOptions): Promise { - const token = await this.ensureAuth(); - const snapshot = await this.pull(); - const manifest = resolvePushManifest(snapshot.manifest, items, options); - const remoteManifest = options?.manifest ? publishableManifest(manifest) : manifest; - - await this.uploadFile(token, 'manifest.json', Buffer.from(JSON.stringify(remoteManifest))); - for (const item of items) { - await this.uploadFile(token, `item-${item.meta.id}.bin`, item.blob); - } - } - - private async findFile(token: string, name: string): Promise { - const q = encodeURIComponent(`name='${name}' and 'appDataFolder' in parents`); - const res = await httpRequest( - `https://www.googleapis.com/drive/v3/files?spaces=appDataFolder&q=${q}&fields=files(id,name)`, - { headers: { Authorization: `Bearer ${token}` } }, - ); - const parsed = JSON.parse(res.body.toString()) as { files?: Array<{ id: string }> }; - return parsed.files?.[0]?.id; - } - - private async downloadFile(token: string, fileId: string): Promise { - const res = await httpRequest(`https://www.googleapis.com/drive/v3/files/${fileId}?alt=media`, { - headers: { Authorization: `Bearer ${token}` }, - }); - return res.body; - } - - private async uploadFile(token: string, name: string, content: Buffer): Promise { - const existing = await this.findFile(token, name); - const metadata = JSON.stringify({ name, parents: ['appDataFolder'] }); - const boundary = 'pgstudio_sync_boundary'; - - if (existing) { - await httpRequest(`https://www.googleapis.com/upload/drive/v3/files/${existing}?uploadType=media`, { - method: 'PATCH', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/octet-stream', - }, - body: content, - }); - return; - } - - const body = Buffer.concat([ - Buffer.from(`--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n${metadata}\r\n`), - Buffer.from(`--${boundary}\r\nContent-Type: application/octet-stream\r\n\r\n`), - content, - Buffer.from(`\r\n--${boundary}--`), - ]); - - await httpRequest('https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart', { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': `multipart/related; boundary=${boundary}`, - }, - body, - }); - } -} diff --git a/src/features/sync/providers/OneDriveSyncProvider.ts b/src/features/sync/providers/OneDriveSyncProvider.ts deleted file mode 100644 index 7de997d..0000000 --- a/src/features/sync/providers/OneDriveSyncProvider.ts +++ /dev/null @@ -1,98 +0,0 @@ -import * as vscode from 'vscode'; -import type { SyncProvider, SyncPushItem, SyncPushOptions, SyncSnapshot, SyncItemMeta } from '../types'; -import { publishableManifest, resolvePushManifest } from '../syncManifest'; -import { entraDeviceFlowSignIn, getOneDriveToken } from '../auth/entraDeviceFlow'; -import { httpRequest } from './httpUtils'; - -const MANIFEST_PATH = '/pgstudio-sync/manifest.json'; - -/** OneDrive appFolder backend via Microsoft Graph. */ -export class OneDriveSyncProvider implements SyncProvider { - readonly id = 'onedrive' as const; - - constructor(private readonly context: vscode.ExtensionContext) {} - - private clientId(): string { - // Placeholder — register a free Entra public client app. - return vscode.workspace - .getConfiguration() - .get('postgresExplorer.sync.onedriveClientId', '00000000-0000-0000-0000-PLACEHOLDER'); - } - - async ensureAuth(): Promise { - let token = await getOneDriveToken(this.context); - if (!token) { - const res = await entraDeviceFlowSignIn(this.context, this.clientId()); - token = res.token; - } - return token; - } - - async testConnection(): Promise<{ ok: boolean; account?: string; error?: string }> { - try { - const token = await this.ensureAuth(); - const res = await httpRequest('https://graph.microsoft.com/v1.0/me', { - headers: { Authorization: `Bearer ${token}` }, - }); - if (res.statusCode >= 400) { - return { ok: false, error: `Graph API ${res.statusCode}` }; - } - const user = JSON.parse(res.body.toString()); - return { ok: true, account: user.userPrincipalName ?? user.mail }; - } catch (e) { - return { ok: false, error: e instanceof Error ? e.message : String(e) }; - } - } - - private appRootUrl(path: string): string { - return `https://graph.microsoft.com/v1.0/me/drive/special/approot:${path}`; - } - - async pull(_sinceRevision?: number): Promise { - const token = await this.ensureAuth(); - const res = await httpRequest(this.appRootUrl(MANIFEST_PATH), { - headers: { Authorization: `Bearer ${token}` }, - }); - - if (res.statusCode === 404) { - return { manifest: [], getBlob: async () => undefined }; - } - - const manifest = JSON.parse(res.body.toString()) as SyncItemMeta[]; - return { - manifest, - getBlob: async (id: string) => { - const blobRes = await httpRequest(this.appRootUrl(`/pgstudio-sync/item-${id}.bin`), { - headers: { Authorization: `Bearer ${token}` }, - }); - if (blobRes.statusCode === 404) { - return undefined; - } - return blobRes.body; - }, - }; - } - - async push(items: SyncPushItem[], options?: SyncPushOptions): Promise { - const token = await this.ensureAuth(); - const snapshot = await this.pull(); - const manifest = resolvePushManifest(snapshot.manifest, items, options); - const remoteManifest = options?.manifest ? publishableManifest(manifest) : manifest; - - await this.uploadFile(token, MANIFEST_PATH, Buffer.from(JSON.stringify(remoteManifest))); - for (const item of items) { - await this.uploadFile(token, `/pgstudio-sync/item-${item.meta.id}.bin`, item.blob); - } - } - - private async uploadFile(token: string, path: string, content: Buffer): Promise { - await httpRequest(this.appRootUrl(path), { - method: 'PUT', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/octet-stream', - }, - body: content, - }); - } -} diff --git a/src/features/sync/providers/PostgresSyncProvider.ts b/src/features/sync/providers/PostgresSyncProvider.ts index fe4441b..9ad7225 100644 --- a/src/features/sync/providers/PostgresSyncProvider.ts +++ b/src/features/sync/providers/PostgresSyncProvider.ts @@ -1,43 +1,87 @@ import * as vscode from 'vscode'; -import type { SyncProvider, SyncPushItem, SyncSnapshot, SyncItemMeta } from '../types'; +import type { PushResult, SyncDelta, SyncOp, SyncProviderV2 } from '../types'; import { ConnectionManager } from '../../../services/ConnectionManager'; +import { getOrCreateDeviceId } from '../deviceId'; +import { LicenseService } from '../../../services/LicenseService'; + +type Client = { + query: (sql: string, params?: unknown[]) => Promise<{ rows: any[] }>; + release: () => void; +}; + /** - * Optional team-sharing backend via pgstudio_sync schema in a shared Postgres DB. - * Uses the connection configured in postgresExplorer.sync.postgresConnectionId. + * Self-hosted sync backend via the pgstudio_sync schema in a Postgres DB the + * user controls. Implements the same git-like v2 protocol as the cloud: a + * monotonic cursor, atomic compare-and-swap push, and a permanent delete log. + * + * The connection id only selects which database to talk to. The space_id (the + * row namespace inside that DB) must be STABLE across a user's devices, so it is + * keyed by license — every device of the same account, pointed at the same DB, + * shares one sync stream. `default` is used when running without a license. */ -export class PostgresSyncProvider implements SyncProvider { +export class PostgresSyncProvider implements SyncProviderV2 { readonly id = 'postgres' as const; constructor(private readonly context: vscode.ExtensionContext) {} private connectionId(): string | undefined { - return vscode.workspace - .getConfiguration() - .get('postgresExplorer.sync.postgresConnectionId'); + return vscode.workspace.getConfiguration().get('postgresExplorer.sync.postgresConnectionId'); } - private async getClient(): Promise<{ query: (sql: string, params?: unknown[]) => Promise<{ rows: unknown[] }>; release: () => void }> { - const connId = this.connectionId(); - if (!connId) { + private space(): string { + if (!this.connectionId()) { throw new Error('postgresExplorer.sync.postgresConnectionId not configured'); } + try { + return LicenseService.getInstance().getLicenseKey() || 'default'; + } catch { + return 'default'; + } + } + + private async getClient(): Promise { + const connId = this.connectionId(); const connections = vscode.workspace.getConfiguration().get('postgresExplorer.connections') || []; const config = connections.find((c) => c.id === connId); if (!config) { throw new Error('Sync Postgres connection not found'); } - const client = await ConnectionManager.getInstance().getPooledClient(config); - return client; + return ConnectionManager.getInstance().getPooledClient(config); + } + + private async ensureSchema(client: Client): Promise { + await client.query('CREATE SCHEMA IF NOT EXISTS pgstudio_sync'); + await client.query("CREATE SEQUENCE IF NOT EXISTS pgstudio_sync.cursor_seq"); + await client.query( + `CREATE TABLE IF NOT EXISTS pgstudio_sync.items_v2 ( + space_id TEXT NOT NULL, item_id TEXT NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('connection','query','notebook')), + blob BYTEA NOT NULL, content_hash TEXT NOT NULL, + version BIGINT NOT NULL, device_id TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (space_id, item_id))`, + ); + await client.query( + 'CREATE INDEX IF NOT EXISTS items_v2_cursor_idx ON pgstudio_sync.items_v2 (space_id, version)', + ); + await client.query( + `CREATE TABLE IF NOT EXISTS pgstudio_sync.deletes_v2 ( + space_id TEXT NOT NULL, item_id TEXT NOT NULL, + version BIGINT NOT NULL, deleted_by TEXT NOT NULL, + deleted_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (space_id, item_id))`, + ); + await client.query( + 'CREATE INDEX IF NOT EXISTS deletes_v2_cursor_idx ON pgstudio_sync.deletes_v2 (space_id, version)', + ); } async testConnection(): Promise<{ ok: boolean; account?: string; error?: string }> { try { const client = await this.getClient(); try { - await client.query('SELECT 1 FROM pgstudio_sync.sync_items LIMIT 1'); + await this.ensureSchema(client); return { ok: true, account: this.connectionId() }; - } catch { - return { ok: false, error: 'pgstudio_sync schema not found — run migration first' }; } finally { client.release(); } @@ -46,115 +90,141 @@ export class PostgresSyncProvider implements SyncProvider { } } - async pull(_sinceRevision?: number): Promise { - const client = await this.getClient(); - const accountId = this.connectionId()!; + private async spaceCursor(client: Client, space: string): Promise { + const res = await client.query( + `SELECT GREATEST( + COALESCE((SELECT MAX(version) FROM pgstudio_sync.items_v2 WHERE space_id = $1), 0), + COALESCE((SELECT MAX(version) FROM pgstudio_sync.deletes_v2 WHERE space_id = $1), 0) + ) AS cursor`, + [space], + ); + return Number(res.rows[0]?.cursor || 0); + } + async pullDelta(since: number): Promise { + const client = await this.getClient(); + const space = this.space(); try { - // Tombstones included — deletes must propagate to other devices. - const rows = await client.query( - `SELECT item_id, kind, content_hash, revision, device_id, deleted, updated_at, blob - FROM pgstudio_sync.sync_items - WHERE account_id = $1`, - [accountId], + await this.ensureSchema(client); + const items = await client.query( + `SELECT item_id, kind, content_hash, version, device_id, blob, updated_at + FROM pgstudio_sync.items_v2 WHERE space_id = $1 AND version > $2 ORDER BY version ASC`, + [space, since], ); - - const manifest: SyncItemMeta[] = (rows.rows as any[]).map((r) => ({ - id: r.item_id, - kind: r.kind, - contentHash: r.content_hash, - revision: r.revision, - updatedAt: new Date(r.updated_at).getTime(), - deviceId: r.device_id, - deleted: r.deleted, - })); - - const blobMap = new Map(); - for (const r of rows.rows as any[]) { - blobMap.set(r.item_id, Buffer.isBuffer(r.blob) ? r.blob : Buffer.from(r.blob)); - } - + const deletes = await client.query( + `SELECT item_id FROM pgstudio_sync.deletes_v2 WHERE space_id = $1 AND version > $2 ORDER BY version ASC`, + [space, since], + ); + const cursor = await this.spaceCursor(client, space); return { - manifest, - getBlob: async (id: string) => blobMap.get(id), + cursor, + upserts: items.rows.map((r) => ({ + meta: { + id: r.item_id, + kind: r.kind, + contentHash: r.content_hash, + version: Number(r.version), + deviceId: r.device_id, + updatedAt: new Date(r.updated_at).getTime(), + }, + blob: Buffer.isBuffer(r.blob) ? r.blob : Buffer.from(r.blob), + })), + deletes: deletes.rows.map((r) => r.item_id), }; } finally { client.release(); } } - async push(items: SyncPushItem[], _options?: import('../types').SyncPushOptions): Promise { + async pushBatch(ops: SyncOp[]): Promise { const client = await this.getClient(); - const accountId = this.connectionId()!; - + const space = this.space(); + const device = getOrCreateDeviceId(this.context); + const accepted: PushResult['accepted'] = []; + const rejected: PushResult['rejected'] = []; try { - for (const item of items) { - await client.query( - `INSERT INTO pgstudio_sync.sync_items - (account_id, item_id, kind, blob, content_hash, revision, device_id, deleted, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now()) - ON CONFLICT (account_id, item_id) DO UPDATE SET - kind = EXCLUDED.kind, - blob = EXCLUDED.blob, - content_hash = EXCLUDED.content_hash, - revision = EXCLUDED.revision, - device_id = EXCLUDED.device_id, - deleted = EXCLUDED.deleted, - updated_at = now()`, - [ - accountId, - item.meta.id, - item.meta.kind, - item.meta.deleted ? Buffer.alloc(0) : item.blob, - item.meta.contentHash, - item.meta.revision, - item.meta.deviceId, - item.meta.deleted, - ], - ); + await this.ensureSchema(client); + await client.query('BEGIN'); + try { + // Serialize concurrent pushers on the same space for a consistent cursor. + await client.query('SELECT pg_advisory_xact_lock(hashtext($1))', [space]); + for (const op of ops) { + const row = op.op === 'delete' + ? await this.applyDelete(client, space, device, op) + : await this.applyUpsert(client, space, device, op); + if (row.new_version != null) { + accepted.push({ itemId: op.itemId, version: Number(row.new_version) }); + } else { + rejected.push({ + itemId: op.itemId, + remoteVersion: row.remote_version == null ? null : Number(row.remote_version), + remoteHash: row.remote_hash ?? null, + }); + } + } + await client.query('COMMIT'); + } catch (e) { + await client.query('ROLLBACK'); + throw e; } + const cursor = await this.spaceCursor(client, space); + return { cursor, accepted, rejected }; } finally { client.release(); } } - private async ensureMetaTable(client: { query: (sql: string, params?: unknown[]) => Promise<{ rows: unknown[] }> }): Promise { - await client.query( - `CREATE TABLE IF NOT EXISTS pgstudio_sync.sync_meta ( - account_id TEXT PRIMARY KEY, - bound_device_id TEXT, - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() - )`, + private async applyUpsert(client: Client, space: string, device: string, op: SyncOp): Promise { + const res = await client.query( + `WITH existing AS ( + SELECT version, content_hash FROM pgstudio_sync.items_v2 WHERE space_id = $1 AND item_id = $2 + ), up AS ( + INSERT INTO pgstudio_sync.items_v2 (space_id, item_id, kind, blob, content_hash, version, device_id, updated_at) + VALUES ($1, $2, $3, $4, $5, nextval('pgstudio_sync.cursor_seq'), $6, now()) + ON CONFLICT (space_id, item_id) DO UPDATE + SET kind = EXCLUDED.kind, blob = EXCLUDED.blob, content_hash = EXCLUDED.content_hash, + version = nextval('pgstudio_sync.cursor_seq'), device_id = EXCLUDED.device_id, updated_at = now() + WHERE pgstudio_sync.items_v2.version <= $7 OR pgstudio_sync.items_v2.content_hash = EXCLUDED.content_hash + RETURNING version + ) + SELECT (SELECT version FROM up) AS new_version, + (SELECT version FROM existing) AS remote_version, + (SELECT content_hash FROM existing) AS remote_hash`, + [space, op.itemId, op.kind, op.blob ?? Buffer.alloc(0), op.contentHash, device, op.baseVersion], ); + return res.rows[0] ?? {}; } - async getBoundDeviceId(): Promise { - const client = await this.getClient(); - try { - await this.ensureMetaTable(client); - const res = await client.query( - 'SELECT bound_device_id FROM pgstudio_sync.sync_meta WHERE account_id = $1', - [this.connectionId()!], - ); - const row = res.rows[0] as { bound_device_id?: string } | undefined; - return row?.bound_device_id ?? undefined; - } finally { - client.release(); - } + private async applyDelete(client: Client, space: string, device: string, op: SyncOp): Promise { + const res = await client.query( + `WITH existing AS ( + SELECT version FROM pgstudio_sync.items_v2 WHERE space_id = $1 AND item_id = $2 + ), del AS ( + DELETE FROM pgstudio_sync.items_v2 + WHERE space_id = $1 AND item_id = $2 AND version <= $3 RETURNING item_id + ), logged AS ( + INSERT INTO pgstudio_sync.deletes_v2 (space_id, item_id, version, deleted_by, deleted_at) + SELECT $1, $2, nextval('pgstudio_sync.cursor_seq'), $4, now() + WHERE EXISTS (SELECT 1 FROM del) OR NOT EXISTS (SELECT 1 FROM existing) + ON CONFLICT (space_id, item_id) DO UPDATE + SET version = nextval('pgstudio_sync.cursor_seq'), deleted_by = EXCLUDED.deleted_by, deleted_at = now() + RETURNING version + ) + SELECT (SELECT version FROM logged) AS new_version, + (SELECT version FROM existing) AS remote_version, + NULL::text AS remote_hash`, + [space, op.itemId, op.baseVersion, device], + ); + return res.rows[0] ?? {}; } - async setBoundDeviceId(deviceId: string): Promise { + async resetSpace(): Promise { const client = await this.getClient(); + const space = this.space(); try { - await this.ensureMetaTable(client); - await client.query( - `INSERT INTO pgstudio_sync.sync_meta (account_id, bound_device_id, updated_at) - VALUES ($1, $2, now()) - ON CONFLICT (account_id) DO UPDATE SET - bound_device_id = EXCLUDED.bound_device_id, - updated_at = now()`, - [this.connectionId()!, deviceId], - ); + await this.ensureSchema(client); + await client.query('DELETE FROM pgstudio_sync.items_v2 WHERE space_id = $1', [space]); + await client.query('DELETE FROM pgstudio_sync.deletes_v2 WHERE space_id = $1', [space]); } finally { client.release(); } diff --git a/src/features/sync/shareCrypto.ts b/src/features/sync/shareCrypto.ts deleted file mode 100644 index 99c6962..0000000 --- a/src/features/sync/shareCrypto.ts +++ /dev/null @@ -1,145 +0,0 @@ -import * as crypto from 'crypto'; - -/** - * Asymmetric sharing primitives for team sync. Each vault owns an X25519 - * identity keypair. To share items, the owner encrypts them with a random - * symmetric "share key" and seals that share key to each grantee's public key - * (libsodium-style sealed box: ephemeral X25519 + HKDF + AES-256-GCM). - * - * Pure module — no vscode/fs imports — so it is unit-testable in isolation. - */ - -const HKDF_INFO = Buffer.from('pgstudio-sync-share-v1'); -const IV_LENGTH = 12; -const TAG_LENGTH = 16; -const X25519_RAW_LEN = 32; - -export interface IdentityKeyPair { - /** Raw 32-byte X25519 public key, base64. */ - publicKey: string; - /** Raw 32-byte X25519 private key, base64. */ - privateKey: string; -} - -function exportRawPublic(key: crypto.KeyObject): Buffer { - // SPKI DER for X25519 ends with the 32-byte raw key. - const der = key.export({ type: 'spki', format: 'der' }); - return der.subarray(der.length - X25519_RAW_LEN); -} - -function exportRawPrivate(key: crypto.KeyObject): Buffer { - // PKCS8 DER for X25519 ends with the 32-byte raw key. - const der = key.export({ type: 'pkcs8', format: 'der' }); - return der.subarray(der.length - X25519_RAW_LEN); -} - -function publicKeyFromRaw(raw: Buffer): crypto.KeyObject { - const prefix = Buffer.from('302a300506032b656e032100', 'hex'); - return crypto.createPublicKey({ - key: Buffer.concat([prefix, raw]), - format: 'der', - type: 'spki', - }); -} - -function privateKeyFromRaw(raw: Buffer): crypto.KeyObject { - const prefix = Buffer.from('302e020100300506032b656e04220420', 'hex'); - return crypto.createPrivateKey({ - key: Buffer.concat([prefix, raw]), - format: 'der', - type: 'pkcs8', - }); -} - -export function generateIdentityKeyPair(): IdentityKeyPair { - const { publicKey, privateKey } = crypto.generateKeyPairSync('x25519'); - return { - publicKey: exportRawPublic(publicKey).toString('base64'), - privateKey: exportRawPrivate(privateKey).toString('base64'), - }; -} - -function deriveSharedKey(shared: Buffer, ephemeralPub: Buffer, recipientPub: Buffer): Buffer { - // Bind the derived key to both public keys to prevent key-reuse attacks. - const salt = Buffer.concat([ephemeralPub, recipientPub]); - return Buffer.from(crypto.hkdfSync('sha256', shared, salt, HKDF_INFO, 32)); -} - -/** - * Seal `plaintext` to a recipient's raw X25519 public key (base64). - * Output: [32B ephemeral pub][12B IV][16B tag][ciphertext], base64. - */ -export function sealTo(recipientPublicKeyB64: string, plaintext: Buffer): string { - const recipientPub = Buffer.from(recipientPublicKeyB64, 'base64'); - if (recipientPub.length !== X25519_RAW_LEN) { - throw new Error('Invalid recipient public key'); - } - const recipientKey = publicKeyFromRaw(recipientPub); - - const ephemeral = crypto.generateKeyPairSync('x25519'); - const ephemeralPubRaw = exportRawPublic(ephemeral.publicKey); - const shared = crypto.diffieHellman({ privateKey: ephemeral.privateKey, publicKey: recipientKey }); - const key = deriveSharedKey(shared, ephemeralPubRaw, recipientPub); - - const iv = crypto.randomBytes(IV_LENGTH); - const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); - const ct = Buffer.concat([cipher.update(plaintext), cipher.final()]); - const tag = cipher.getAuthTag(); - - return Buffer.concat([ephemeralPubRaw, iv, tag, ct]).toString('base64'); -} - -/** Open a sealed blob with the recipient's raw X25519 private key (base64). */ -export function openSealed(recipientPrivateKeyB64: string, sealedB64: string): Buffer { - const recipientPrivRaw = Buffer.from(recipientPrivateKeyB64, 'base64'); - if (recipientPrivRaw.length !== X25519_RAW_LEN) { - throw new Error('Invalid recipient private key'); - } - const blob = Buffer.from(sealedB64, 'base64'); - if (blob.length < X25519_RAW_LEN + IV_LENGTH + TAG_LENGTH) { - throw new Error('Sealed blob too short'); - } - - const ephemeralPubRaw = blob.subarray(0, X25519_RAW_LEN); - const iv = blob.subarray(X25519_RAW_LEN, X25519_RAW_LEN + IV_LENGTH); - const tag = blob.subarray(X25519_RAW_LEN + IV_LENGTH, X25519_RAW_LEN + IV_LENGTH + TAG_LENGTH); - const ct = blob.subarray(X25519_RAW_LEN + IV_LENGTH + TAG_LENGTH); - - const recipientPriv = privateKeyFromRaw(recipientPrivRaw); - const recipientPubRaw = exportRawPublic(crypto.createPublicKey(recipientPriv)); - const shared = crypto.diffieHellman({ - privateKey: recipientPriv, - publicKey: publicKeyFromRaw(ephemeralPubRaw), - }); - const key = deriveSharedKey(shared, ephemeralPubRaw, recipientPubRaw); - - const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv); - decipher.setAuthTag(tag); - return Buffer.concat([decipher.update(ct), decipher.final()]); -} - -/** Random symmetric share key (AES-256), base64. */ -export function generateShareKey(): string { - return crypto.randomBytes(32).toString('base64'); -} - -/** Encrypt item plaintext with a share key. Output base64: [12B IV][16B tag][ct]. */ -export function encryptWithShareKey(shareKeyB64: string, plaintext: Buffer): string { - const key = Buffer.from(shareKeyB64, 'base64'); - const iv = crypto.randomBytes(IV_LENGTH); - const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); - const ct = Buffer.concat([cipher.update(plaintext), cipher.final()]); - const tag = cipher.getAuthTag(); - return Buffer.concat([iv, tag, ct]).toString('base64'); -} - -export function decryptWithShareKey(shareKeyB64: string, blobB64: string): Buffer { - const key = Buffer.from(shareKeyB64, 'base64'); - const blob = Buffer.from(blobB64, 'base64'); - const iv = blob.subarray(0, IV_LENGTH); - const tag = blob.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH); - const ct = blob.subarray(IV_LENGTH + TAG_LENGTH); - const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv); - decipher.setAuthTag(tag); - return Buffer.concat([decipher.update(ct), decipher.final()]); -} diff --git a/src/features/sync/syncChangeStats.ts b/src/features/sync/syncChangeStats.ts deleted file mode 100644 index bc01367..0000000 --- a/src/features/sync/syncChangeStats.ts +++ /dev/null @@ -1,119 +0,0 @@ -import type { - SyncChangeSummary, - SyncDirectionSummary, - SyncItemMeta, - SyncKind, - SyncKindChangeCounts, - SyncPushItem, -} from './types'; - -const EMPTY_COUNTS: SyncKindChangeCounts = { created: 0, updated: 0, deleted: 0 }; - -export function emptySyncChangeSummary(): SyncChangeSummary { - return { - pushed: { - connections: { ...EMPTY_COUNTS }, - queries: { ...EMPTY_COUNTS }, - notebooks: { ...EMPTY_COUNTS }, - }, - pulled: { - connections: { ...EMPTY_COUNTS }, - queries: { ...EMPTY_COUNTS }, - notebooks: { ...EMPTY_COUNTS }, - }, - }; -} - -type TrackedKind = keyof SyncDirectionSummary; - -function trackedKind(kind: SyncKind): TrackedKind | undefined { - if (kind === 'connection') { - return 'connections'; - } - if (kind === 'query') { - return 'queries'; - } - if (kind === 'notebook') { - return 'notebooks'; - } - return undefined; -} - -function metaKey(m: SyncItemMeta): string { - return `${m.kind}:${m.id}`; -} - -function classifyOutgoing(meta: SyncItemMeta, baseByKey: Map): keyof SyncKindChangeCounts { - if (meta.deleted) { - return 'deleted'; - } - const base = baseByKey.get(metaKey(meta)); - if (!base || base.deleted) { - return 'created'; - } - return 'updated'; -} - -function classifyIncoming(meta: SyncItemMeta, localByKey: Map): keyof SyncKindChangeCounts { - if (meta.deleted) { - return 'deleted'; - } - const local = localByKey.get(metaKey(meta)); - if (!local || local.deleted) { - return 'created'; - } - return 'updated'; -} - -export function buildSyncChangeSummary( - baseManifest: SyncItemMeta[], - localItems: Array<{ meta: SyncItemMeta }>, - pushed: SyncPushItem[], - pulled: Array<{ meta: SyncItemMeta }>, -): SyncChangeSummary { - const summary = emptySyncChangeSummary(); - const baseByKey = new Map(baseManifest.map((m) => [metaKey(m), m])); - const localByKey = new Map(localItems.map((i) => [metaKey(i.meta), i.meta])); - - for (const item of pushed) { - const bucket = trackedKind(item.meta.kind); - if (!bucket) { - continue; - } - const action = classifyOutgoing(item.meta, baseByKey); - summary.pushed[bucket][action] += 1; - } - - for (const item of pulled) { - const bucket = trackedKind(item.meta.kind); - if (!bucket) { - continue; - } - const action = classifyIncoming(item.meta, localByKey); - summary.pulled[bucket][action] += 1; - } - - return summary; -} - -export function hasSyncChanges(summary: SyncChangeSummary): boolean { - for (const direction of [summary.pushed, summary.pulled]) { - for (const counts of Object.values(direction)) { - if (counts.created > 0 || counts.updated > 0 || counts.deleted > 0) { - return true; - } - } - } - return false; -} - -/** Compact log line: `conn+2/~1/-0 query+0/~0/-0 nb+1/~0/-0` */ -export function formatCountsLine(direction: SyncDirectionSummary): string { - const fmt = (label: string, counts: SyncKindChangeCounts): string => - `${label}+${counts.created}/~${counts.updated}/-${counts.deleted}`; - return [ - fmt('conn', direction.connections), - fmt('query', direction.queries), - fmt('nb', direction.notebooks), - ].join(' '); -} diff --git a/src/features/sync/syncCommands.ts b/src/features/sync/syncCommands.ts index 78cdc3a..0aa9396 100644 --- a/src/features/sync/syncCommands.ts +++ b/src/features/sync/syncCommands.ts @@ -1,13 +1,13 @@ import * as vscode from 'vscode'; import { SyncController } from './SyncController'; -import { VaultService } from './VaultService'; +import { WorkspaceSharingService } from './WorkspaceSharingService'; import { allowedSyncProviders, ProFeature, requirePro, } from '../../services/featureGates'; -import { GistSyncProvider } from './providers/GistSyncProvider'; import { readNotebookSyncId } from './notebookSyncId'; +import type { WorkspaceView } from './types'; /** Tree context-menu item shape (saved query or notebook). */ type SyncContextTreeItem = { @@ -47,174 +47,122 @@ export async function cmdSyncSetup(_context: vscode.ExtensionContext): Promise { + const workspaces = await service.listWorkspaces(); + const owned = workspaces.filter((w) => w.role === 'owner'); + const pick = await vscode.window.showQuickPick( + [ + { label: '$(add) New workspace…', id: '__new__' }, + ...owned.map((w) => ({ label: `$(organization) ${w.name}`, description: w.ownerEmail, id: w.spaceId })), + ], + { title: 'Share to workspace', placeHolder: 'Choose a team workspace' }, + ); + if (!pick) { + return undefined; + } + if (pick.id === '__new__') { + const name = await vscode.window.showInputBox({ + title: 'New workspace', + prompt: 'Name for the shared team workspace', + ignoreFocusOut: true, + }); + if (!name?.trim()) { + return undefined; + } + return service.createWorkspace(name.trim()); + } + return owned.find((w) => w.spaceId === pick.id); +} + +/** Create/select a team workspace and invite a member. */ export async function cmdSyncShare( context: vscode.ExtensionContext, - treeItem?: SyncContextTreeItem, + _treeItem?: SyncContextTreeItem, ): Promise { if (!(await requirePro(ProFeature.SyncSharing))) { return; } - const controller = SyncController.getInstance(); - if (controller.getConfig().providerId !== 'cloud') { + if (SyncController.getInstance().getConfig().providerId !== 'cloud') { await vscode.window.showWarningMessage( - 'Team sharing requires the NexQL Cloud sync backend. Set it up under NexQL Sync: Set Up Sync.', + 'Team workspaces require the NexQL Cloud sync backend. Set it up under NexQL Sync: Set Up Sync.', ); return; } - const shareable = controller.listSyncedItems().filter((i) => i.kind === 'query' || i.kind === 'notebook'); - if (shareable.length === 0) { - await vscode.window.showInformationMessage('No notebooks or saved queries are available to share yet.'); - return; - } - - const fromMenu = await resolveSyncItemIdFromTreeItem(treeItem); - let itemIds: string[]; - if (fromMenu) { - if (!shareable.some((i) => i.id === fromMenu)) { - await vscode.window.showWarningMessage( - 'This item is not in the sync index yet. Run sync first, then share again.', - ); + const service = new WorkspaceSharingService(context); + try { + const workspace = await pickOrCreateWorkspace(service); + if (!workspace) { return; } - itemIds = [fromMenu]; - } else { - const picks = await vscode.window.showQuickPick( - shareable.map((i) => ({ - label: i.name || i.id, - description: i.kind === 'notebook' ? 'Notebook' : 'Saved query', - id: i.id, - })), - { title: 'Share items', placeHolder: 'Select items to share', canPickMany: true }, - ); - if (!picks?.length) { + const email = await vscode.window.showInputBox({ + title: `Invite to "${workspace.name}"`, + prompt: "Team member's account email (they must have NexQL sync enabled)", + placeHolder: 'teammate@example.com', + ignoreFocusOut: true, + validateInput: (v) => (/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v.trim()) ? undefined : 'Enter a valid email'), + }); + if (!email) { return; } - itemIds = picks.map((p) => p.id); - } - - const granteeEmail = await vscode.window.showInputBox({ - title: 'Share with', - prompt: "Team member's account email (they must have NexQL sync enabled)", - placeHolder: 'teammate@example.com', - ignoreFocusOut: true, - validateInput: (v) => (/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v.trim()) ? undefined : 'Enter a valid email'), - }); - if (!granteeEmail) { - return; - } - - try { - const { SharingService } = await import('./SharingService'); - const count = await vscode.window.withProgress( - { location: vscode.ProgressLocation.Notification, title: 'Sharing items…' }, - () => new SharingService(context).shareItems(granteeEmail.trim(), itemIds), - ); - await vscode.window.showInformationMessage( - count > 0 - ? `Shared ${count} item${count === 1 ? '' : 's'} with ${granteeEmail.trim()}.` - : 'Nothing was shared — selected items could not be read.', + const role = await vscode.window.showQuickPick( + [ + { label: 'Editor', detail: 'Can read and write shared items', id: 'editor' as const }, + { label: 'Viewer', detail: 'Read-only access', id: 'viewer' as const }, + ], + { title: 'Member role' }, ); + if (!role) { + return; + } + await service.addMember(workspace.spaceId, email.trim(), role.id); + await vscode.window.showInformationMessage(`Invited ${email.trim()} to "${workspace.name}" as ${role.id}.`); } catch (e) { await vscode.window.showErrorMessage(`Share failed: ${e instanceof Error ? e.message : String(e)}`); } } -/** Review and import items other team members have shared with you. */ +/** View team workspaces you belong to and manage members of ones you own. */ export async function cmdSyncImportShares(context: vscode.ExtensionContext): Promise { if (!(await requirePro(ProFeature.SyncSharing))) { return; } - const { SharingService } = await import('./SharingService'); - const service = new SharingService(context); - - let shares; + const service = new WorkspaceSharingService(context); + let workspaces: WorkspaceView[]; try { - shares = await vscode.window.withProgress( - { location: vscode.ProgressLocation.Notification, title: 'Loading shared items…' }, - () => service.listIncomingShares(), - ); + workspaces = await service.listWorkspaces(); } catch (e) { - await vscode.window.showErrorMessage(`Could not load shares: ${e instanceof Error ? e.message : String(e)}`); + await vscode.window.showErrorMessage(`Could not load workspaces: ${e instanceof Error ? e.message : String(e)}`); return; } - if (!shares.length) { - await vscode.window.showInformationMessage('No one has shared items with you yet.'); + if (!workspaces.length) { + await vscode.window.showInformationMessage('You are not in any team workspaces yet.'); return; } - const picks = await vscode.window.showQuickPick( - shares.map((s) => ({ - label: s.name || s.shareId, - description: `${s.kind === 'notebook' ? 'Notebook' : 'Saved query'} · from ${s.ownerEmail}`, - share: s, - })), - { title: 'Import shared items', placeHolder: 'Select items to import', canPickMany: true }, + const pick = await vscode.window.showQuickPick( + workspaces.map((w) => ({ label: `$(organization) ${w.name}`, description: `${w.role} · ${w.ownerEmail}`, ws: w })), + { title: 'Team workspaces', placeHolder: 'Select a workspace to view members' }, ); - if (!picks?.length) { + if (!pick) { return; } - - const mode = await vscode.window.showQuickPick( - [ - { label: 'Merge into my library', detail: 'Re-importing later updates these items in place', id: 'merge' as const }, - { label: 'Import as new copies', detail: 'Detached duplicates with fresh ids', id: 'copy' as const }, - ], - { title: 'How should shared items be imported?' }, + const members = await service.listMembers(pick.ws.spaceId); + const memberPick = await vscode.window.showQuickPick( + members.map((m) => ({ label: m.email, description: m.role, member: m })), + { title: `Members of "${pick.ws.name}"`, placeHolder: pick.ws.role === 'owner' ? 'Select a member to remove' : 'Members (read-only)' }, ); - if (!mode) { - return; - } - - // Optionally attach one of the grantee's own connections (never the owner's). - const connections = vscode.workspace.getConfiguration().get('postgresExplorer.connections') || []; - let connectionId: string | undefined; - if (connections.length > 0) { - const connPick = await vscode.window.showQuickPick( - [ - { label: 'No connection (attach later)', id: undefined as string | undefined }, - ...connections.map((c) => ({ label: c.name ?? `${c.host}:${c.port}`, id: String(c.id) })), - ], - { title: 'Attach a connection to imported items?' }, + if (memberPick && pick.ws.role === 'owner' && memberPick.member.role !== 'owner') { + const confirm = await vscode.window.showWarningMessage( + `Remove ${memberPick.member.email} from "${pick.ws.name}"?`, + 'Remove', ); - if (!connPick) { - return; + if (confirm === 'Remove') { + await service.removeMember(pick.ws.spaceId, memberPick.member.email); + await vscode.window.showInformationMessage(`Removed ${memberPick.member.email}.`); } - connectionId = connPick.id; - } - - try { - const count = await vscode.window.withProgress( - { location: vscode.ProgressLocation.Notification, title: 'Importing…' }, - () => service.importShares(picks.map((p) => p.share), mode.id, connectionId), - ); - await vscode.window.showInformationMessage(`Imported ${count} shared item${count === 1 ? '' : 's'}.`); - } catch (e) { - await vscode.window.showErrorMessage(`Import failed: ${e instanceof Error ? e.message : String(e)}`); - } -} - -export async function cmdSyncLinkGist(context: vscode.ExtensionContext): Promise { - if (!(await requirePro(ProFeature.CloudBackup))) { - return; - } - const config = SyncController.getInstance().getConfig(); - if (config.providerId !== 'gist') { - await vscode.window.showWarningMessage('Link Gist is only for the GitHub Gist sync backend.'); - return; - } - const provider = new GistSyncProvider(context); - const linked = await provider.linkExistingGistInteractive(); - if (!linked) { - return; } - const gistId = await context.secrets.get('postgresExplorer.sync.gistId'); - await SyncController.getInstance().saveConfig({ ...config, gistId }); - await vscode.window.withProgress( - { location: vscode.ProgressLocation.Notification, title: 'Pulling from linked gist…' }, - () => SyncController.getInstance().runSync() ?? Promise.resolve(), - ); } export async function cmdSyncNow(): Promise { @@ -246,7 +194,7 @@ export async function cmdSyncPreview(): Promise { } export async function cmdSyncConflicts(): Promise { - await vscode.commands.executeCommand('postgres-explorer.settingsHub', { section: 'sync', tab: 'conflicts' }); + await vscode.commands.executeCommand('postgres-explorer.settingsHub', { section: 'sync', tab: 'items' }); } export async function cmdSyncReplaceLocal(): Promise { @@ -254,14 +202,15 @@ export async function cmdSyncReplaceLocal(): Promise { return; } const typed = await vscode.window.showInputBox({ - title: 'Replace local with cloud', - prompt: 'Type REPLACE to confirm', + title: 'Clear local & pull from cloud', + prompt: 'Type REPLACE to wipe local synced state and pull everything fresh from the cloud', ignoreFocusOut: true, }); if (typed !== 'REPLACE') { return; } - await SyncController.getInstance().replaceLocalWithCloud(); + const ok = await SyncController.getInstance().replaceLocalWithCloud(); + void vscode.window.showInformationMessage(ok ? 'Local data replaced from cloud.' : 'Replace failed.'); } export async function cmdSyncReplaceRemote(): Promise { @@ -269,14 +218,15 @@ export async function cmdSyncReplaceRemote(): Promise { return; } const typed = await vscode.window.showInputBox({ - title: 'Replace cloud with local', - prompt: 'Type REPLACE to confirm', + title: 'Clear cloud & push from this device', + prompt: "Type REPLACE to wipe the cloud copy and push this device's data", ignoreFocusOut: true, }); if (typed !== 'REPLACE') { return; } - await SyncController.getInstance().replaceCloudWithLocal(); + const ok = await SyncController.getInstance().replaceCloudWithLocal(); + void vscode.window.showInformationMessage(ok ? 'Cloud replaced from local.' : 'Replace failed.'); } export async function cmdSyncRebuildIndex(): Promise { @@ -285,7 +235,7 @@ export async function cmdSyncRebuildIndex(): Promise { } const typed = await vscode.window.showInputBox({ title: 'Rebuild sync index', - prompt: 'Type REPLACE to confirm', + prompt: 'Type REPLACE to rebuild the local sync index from disk', ignoreFocusOut: true, }); if (typed !== 'REPLACE') { @@ -313,44 +263,17 @@ export async function cmdSyncExcludeItem(itemId?: string): Promise { export async function cmdSyncStatus(): Promise { const controller = SyncController.getInstance(); const config = controller.getConfig(); - const status = controller.getStatus(); - const conflicts = controller.getConflictCount(); - await vscode.window.showInformationMessage( - `Sync: ${status} | provider: ${config.providerId ?? 'none'} | conflicts: ${conflicts}`, + `Sync: ${controller.getStatus()} | provider: ${config.providerId ?? 'none'} | conflicts: ${controller.getConflictCount()}`, ); } -export async function cmdSyncShowSecretKey(context: vscode.ExtensionContext): Promise { - if (!(await requirePro(ProFeature.CloudBackup))) { - return; - } - const vault = VaultService.getInstance(context); - if (!vault.isUnlocked()) { - await vscode.window.showWarningMessage( - 'Unlock your vault first (Settings → Cloud Sync → wizard, or re-run setup).', - ); - return; - } - const generation = vault.getGeneration() ?? ''; - const legacyEmail = vault.getAccountEmail() ?? SyncController.getInstance().getConfig().accountEmail ?? ''; - const secretKey = await vscode.window.showInputBox({ - title: 'Export recovery kit', - prompt: 'Enter your secret key to re-export the recovery kit (not stored by PgStudio)', - password: true, - ignoreFocusOut: true, - }); - if (!secretKey) { - return; - } - try { - await vault.unlock(secretKey, legacyEmail || undefined); - } catch { - await vscode.window.showErrorMessage('Secret key did not unlock the vault.'); - return; - } - const { SyncSetupWizard } = await import('./SyncSetupWizard'); - await new SyncSetupWizard(context).exportRecoveryKit(generation, secretKey); +/** Security/privacy info — pass 1 stores items in plaintext (TLS in transit). */ +export async function cmdSyncShowSecretKey(_context: vscode.ExtensionContext): Promise { + await vscode.window.showInformationMessage( + 'PgStudio Sync stores your connections (without passwords), saved queries and notebooks on the sync backend in plain text, protected by TLS in transit and your account credentials. Passwords and SSH/SSL key paths never leave this device. End-to-end encryption is planned for a future release.', + { modal: true }, + ); } export async function cmdSyncPause(): Promise { @@ -360,12 +283,12 @@ export async function cmdSyncPause(): Promise { const controller = SyncController.getInstance(); const config = controller.getConfig(); await controller.saveConfig({ ...config, paused: !config.paused }); - vscode.window.showInformationMessage(config.paused ? 'Sync resumed' : 'Sync paused'); + void vscode.window.showInformationMessage(config.paused ? 'Sync resumed' : 'Sync paused'); } export async function cmdSyncSignOut(): Promise { const confirm = await vscode.window.showWarningMessage( - 'Sign out of sync? Local data is kept; remote vault remains.', + 'Sign out of sync? Local data is kept; the cloud copy remains.', 'Sign Out', ); if (confirm === 'Sign Out') { @@ -382,14 +305,10 @@ export async function cmdSyncStatusMenu(context?: vscode.ExtensionContext): Prom } const configured = !!config.providerId; - const gistItems = config.providerId === 'gist' - ? [{ label: '$(link) Link GitHub Gist…', id: 'linkGist' }] - : []; - // Team sharing rides the NexQL Cloud backend only. const shareItems = config.providerId === 'cloud' ? [ - { label: '$(person-add) Share Items…', id: 'share' }, - { label: '$(cloud-download) Import Shared Items…', id: 'importShares' }, + { label: '$(person-add) Share to Workspace…', id: 'share' }, + { label: '$(organization) Manage Workspaces…', id: 'importShares' }, ] : []; const items = configured @@ -398,15 +317,10 @@ export async function cmdSyncStatusMenu(context?: vscode.ExtensionContext): Prom { label: '$(cloud-download) Pull Only', id: 'pull' }, { label: '$(cloud-upload) Push Only', id: 'push' }, { label: '$(eye) Preview Sync…', id: 'preview' }, - { label: '$(warning) Resolve Conflicts…', id: 'conflicts' }, - ...gistItems, ...shareItems, { label: '$(info) Show Status', id: 'status' }, - { label: '$(key) Export Recovery Kit…', id: 'secret' }, - { - label: config.paused ? '$(play) Resume Sync' : '$(debug-pause) Pause Sync', - id: 'pause', - }, + { label: '$(shield) Privacy & Security', id: 'secret' }, + { label: config.paused ? '$(play) Resume Sync' : '$(debug-pause) Pause Sync', id: 'pause' }, { label: '$(sign-out) Sign Out', id: 'signout' }, { label: '$(settings-gear) Open Settings', id: 'settings' }, ] @@ -425,16 +339,11 @@ export async function cmdSyncStatusMenu(context?: vscode.ExtensionContext): Prom switch (pick.id) { case 'setup': - if (!context) { - await vscode.window.showErrorMessage('Sync setup requires extension context.'); - return; + if (context) { + await cmdSyncSetup(context); } - await cmdSyncSetup(context); break; case 'now': - if (!(await requirePro(ProFeature.CloudBackup))) { - return; - } await cmdSyncNow(); break; case 'pull': @@ -446,15 +355,6 @@ export async function cmdSyncStatusMenu(context?: vscode.ExtensionContext): Prom case 'preview': await cmdSyncPreview(); break; - case 'conflicts': - await cmdSyncConflicts(); - break; - case 'linkGist': - if (!context) { - return; - } - await cmdSyncLinkGist(context); - break; case 'share': if (context) { await cmdSyncShare(context); diff --git a/src/features/sync/syncManifest.ts b/src/features/sync/syncManifest.ts deleted file mode 100644 index 4a5aebb..0000000 --- a/src/features/sync/syncManifest.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { SyncItemMeta, SyncPushItem } from './types'; - -/** Merge push items into a remote manifest when no authoritative manifest is supplied. */ -export function mergeRemoteManifest(remote: SyncItemMeta[], items: SyncPushItem[]): SyncItemMeta[] { - const map = new Map(remote.map((m) => [m.id, m])); - for (const item of items) { - map.set(item.meta.id, item.meta); - } - return Array.from(map.values()); -} - -/** Resolve the manifest to publish: prefer the post-merge base when provided. */ -export function resolvePushManifest( - remote: SyncItemMeta[], - items: SyncPushItem[], - options?: { manifest?: SyncItemMeta[] }, -): SyncItemMeta[] { - return options?.manifest ?? mergeRemoteManifest(remote, items); -} - -/** Active (non-tombstone) item ids in a manifest. */ -export function activeManifestIds(manifest: SyncItemMeta[]): Set { - return new Set(manifest.filter((m) => !m.deleted).map((m) => m.id)); -} - -/** Manifest written to remote storage — tombstones stay in local base for merge only. */ -export function publishableManifest(manifest: SyncItemMeta[]): SyncItemMeta[] { - return manifest.filter((m) => !m.deleted); -} - -export const SYNC_BLOB_PREFIX = 'item-'; -export const SYNC_BLOB_SUFFIX = '.bin'; - -export function syncBlobName(id: string): string { - return `${SYNC_BLOB_PREFIX}${id}${SYNC_BLOB_SUFFIX}`; -} - -export function parseSyncBlobId(filename: string): string | undefined { - if (!filename.startsWith(SYNC_BLOB_PREFIX) || !filename.endsWith(SYNC_BLOB_SUFFIX)) { - return undefined; - } - return filename.slice(SYNC_BLOB_PREFIX.length, -SYNC_BLOB_SUFFIX.length); -} diff --git a/src/features/sync/syncPreviewUtils.ts b/src/features/sync/syncPreviewUtils.ts deleted file mode 100644 index e074935..0000000 --- a/src/features/sync/syncPreviewUtils.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { MergeConflict, SyncItemMeta, SyncKind, SyncPreviewItem, SyncPushItem } from './types'; - -function metaKey(m: SyncItemMeta): string { - return `${m.kind}:${m.id}`; -} - -function classifyOutgoing(meta: SyncItemMeta, baseByKey: Map): SyncPreviewItem['changeType'] { - if (meta.deleted) { - return 'delete'; - } - const base = baseByKey.get(metaKey(meta)); - if (!base || base.deleted) { - return 'create'; - } - return 'update'; -} - -function classifyIncoming(meta: SyncItemMeta, localByKey: Map): SyncPreviewItem['changeType'] { - if (meta.deleted) { - return 'delete'; - } - const local = localByKey.get(metaKey(meta)); - if (!local || local.deleted) { - return 'create'; - } - return 'update'; -} - -export function buildPreviewFromMerge( - baseManifest: SyncItemMeta[], - localItems: Array<{ meta: SyncItemMeta }>, - toPush: SyncPushItem[], - toApply: Array<{ meta: SyncItemMeta }>, - conflicts: MergeConflict[], - nameFor: (id: string, kind: SyncKind) => string | undefined, -): { outgoing: SyncPreviewItem[]; incoming: SyncPreviewItem[]; conflictItems: SyncPreviewItem[] } { - const baseByKey = new Map(baseManifest.map((m) => [metaKey(m), m])); - const localByKey = new Map(localItems.map((i) => [metaKey(i.meta), i.meta])); - - const outgoing: SyncPreviewItem[] = toPush.map((item) => ({ - id: item.meta.id, - kind: item.meta.kind, - name: nameFor(item.meta.id, item.meta.kind), - changeType: classifyOutgoing(item.meta, baseByKey), - deviceId: item.meta.deviceId, - })); - - const incoming: SyncPreviewItem[] = toApply.map(({ meta }) => ({ - id: meta.id, - kind: meta.kind, - name: nameFor(meta.id, meta.kind), - changeType: classifyIncoming(meta, localByKey), - deviceId: meta.deviceId, - })); - - const conflictItems: SyncPreviewItem[] = conflicts.map((c) => ({ - id: c.id, - kind: c.kind, - name: c.localName, - changeType: 'conflict', - deviceId: c.remoteDeviceId, - })); - - return { outgoing, incoming, conflictItems }; -} - -export function mergeBaseManifestPartial( - oldBase: SyncItemMeta[], - newBase: SyncItemMeta[], - syncedKeys: Set, -): SyncItemMeta[] { - const result = new Map(oldBase.map((m) => [metaKey(m), m])); - for (const m of newBase) { - const key = metaKey(m); - if (syncedKeys.has(key)) { - result.set(key, m); - } - } - return [...result.values()]; -} - -export function isConflictCopyId(id: string): boolean { - return /-conflict-\d+$/.test(id); -} diff --git a/src/features/sync/types.ts b/src/features/sync/types.ts index 31cefd1..448a7c6 100644 --- a/src/features/sync/types.ts +++ b/src/features/sync/types.ts @@ -1,8 +1,14 @@ -/** Sync item kinds — connection metadata, saved queries, notebooks, optional credential bundle. */ -export type SyncKind = 'connection' | 'query' | 'notebook' | 'secrets'; +/** Sync item kinds — connection metadata, saved queries, notebooks. */ +export type SyncKind = 'connection' | 'query' | 'notebook'; -export type SyncProviderId = 'gist' | 'onedrive' | 'gdrive' | 'cloud' | 'postgres'; +export type SyncProviderId = 'cloud' | 'postgres'; +/** + * Local view of an item produced by a *SyncService. `revision` is vestigial + * (the git-like engine orders by server `version` + content hash) but kept so + * the disk-mapping services stay untouched. `updatedAt` is the local edit time, + * used for last-writer-wins resolution against remote. + */ export interface SyncItemMeta { id: string; kind: SyncKind; @@ -13,31 +19,60 @@ export interface SyncItemMeta { deleted: boolean; } -export interface SyncSnapshot { - manifest: SyncItemMeta[]; - getBlob(id: string): Promise; +// ── v2 git-like sync protocol ───────────────────────────────────────────────── + +/** Metadata for an item as it lives on the server. */ +export interface RemoteItemMeta { + id: string; + kind: SyncKind; + contentHash: string; + /** Monotonic server version (sync cursor value at write time). */ + version: number; + deviceId: string; + /** Server write time, epoch ms. */ + updatedAt: number; +} + +/** Delta returned by a pull: everything past the client cursor. */ +export interface SyncDelta { + cursor: number; + upserts: Array<{ meta: RemoteItemMeta; blob: Buffer }>; + deletes: string[]; } -export interface SyncPushItem { - meta: SyncItemMeta; - blob: Buffer; +/** A single push operation with optimistic-concurrency base version. */ +export interface SyncOp { + op: 'upsert' | 'delete'; + itemId: string; + kind: SyncKind; + /** Server version the client last saw (0 = never synced). */ + baseVersion: number; + contentHash?: string; + blob?: Buffer; } -/** Optional push context — authoritative post-merge manifest for remote cleanup. */ -export interface SyncPushOptions { - manifest?: SyncItemMeta[]; +/** Server response to a push batch. */ +export interface PushResult { + cursor: number; + accepted: Array<{ itemId: string; version: number }>; + rejected: Array<{ itemId: string; remoteVersion: number | null; remoteHash: string | null }>; } -export interface SyncProvider { +/** + * v2 provider — cursor-based delta sync with atomic batch push and a permanent + * server-side delete log. Implemented by Cloud (HTTP) and self-hosted Postgres. + */ +export interface SyncProviderV2 { readonly id: SyncProviderId; - pull(sinceRevision?: number): Promise; - push(items: SyncPushItem[], options?: SyncPushOptions): Promise; + pullDelta(since: number): Promise; + pushBatch(ops: SyncOp[]): Promise; + /** Wipe the remote space (powers "clear cloud & push"). */ + resetSpace(): Promise; testConnection(): Promise<{ ok: boolean; account?: string; error?: string }>; - /** Device binding for free-tier single-device backup. Optional per backend. */ - getBoundDeviceId?(): Promise; - setBoundDeviceId?(deviceId: string): Promise; } +// ── Run results / options ───────────────────────────────────────────────────── + export interface SyncKindChangeCounts { created: number; updated: number; @@ -99,15 +134,6 @@ export interface InboundEntry { appliedAt: number; } -export interface OutgoingShareView { - shareId: string; - granteeEmail: string; - kind: SyncKind; - name?: string; - createdAt: string; - revoked: boolean; -} - export interface CloudQuotaView { bytesUsed: number; bytesLimit: number; @@ -130,20 +156,18 @@ export type SyncStatus = | 'conflict' | 'error' | 'paused' - | 'locked' | 'not_configured'; export interface SyncConfig { providerId?: SyncProviderId; - /** GitHub Gist backend — remote vault id (also in SecretStorage per editor). */ - gistId?: string; syncConnections: boolean; syncQueries: boolean; syncNotebooks: boolean; - syncPasswords: boolean; paused: boolean; accountEmail?: string; - vaultGeneration?: string; + /** Active workspace (shared space id). Undefined = personal space. */ + spaceId?: string; + spaceName?: string; /** Per-item opt-outs: ids that are neither pushed nor applied on this device. */ excludedIds?: string[]; } @@ -152,13 +176,12 @@ export interface SyncConfig { export interface SyncedItemView { id: string; kind: SyncKind; - /** Local display name; remote-only items have none until first pull. */ name?: string; updatedAt?: number; deviceId?: string; - revision?: number; excluded: boolean; - deleted: boolean; + /** Per-item inclusion state for the settings table. */ + itemStatus: 'excluded' | 'pending' | 'synced' | 'local'; } export type SyncActivityAction = 'create' | 'update' | 'rename' | 'delete'; @@ -196,23 +219,6 @@ export interface PathOverrides { }; } -export interface MergeConflict { - id: string; - kind: SyncKind; - localName: string; - remoteDeviceId: string; - winner: 'local' | 'remote'; - loserCopyName: string; -} - -export interface MergeResult { - toPush: SyncPushItem[]; - toApply: Array<{ meta: SyncItemMeta; plaintext: Buffer }>; - conflicts: MergeConflict[]; - skipped: Array<{ id: string; reason: string }>; - newBaseManifest: SyncItemMeta[]; -} - export interface ConnectionSyncPayload { id: string; name?: string; @@ -231,10 +237,6 @@ export interface ConnectionSyncPayload { }; } -export interface SecretsSyncPayload { - passwords: Record; -} - export interface NotebookSyncPayload { syncId: string; name: string; @@ -245,18 +247,25 @@ export interface NotebookSyncPayload { cells: Array<{ value: string; kind?: string; language?: string }>; } -export interface VaultManifest { - /** v2 uses random salt; v1 (legacy) uses email as scrypt salt. */ - version?: 1 | 2; - generation: string; - wrappedVaultKey: string; - salt: string; - kdf?: 'scrypt'; - /** Present on v1 vaults only. */ - email?: string; +// ── Team workspaces (server-ACL sharing) ────────────────────────────────────── + +export type WorkspaceRole = 'owner' | 'editor' | 'viewer'; + +export interface WorkspaceView { + spaceId: string; + name: string; + ownerEmail: string; + role: WorkspaceRole; } -/** Device authorization flow responses (nexql.astrx.dev). */ +export interface WorkspaceMemberView { + email: string; + role: WorkspaceRole; + addedAt?: string; +} + +// ── Device authorization flow (nexql.astrx.dev) ─────────────────────────────── + export interface DeviceAuthStartResponse { device_code: string; user_code: string; @@ -275,13 +284,3 @@ export interface DeviceAuthTokenResponse { error?: string; error_description?: string; } - -export interface CloudSyncManifestEntry { - item_id: string; - kind: SyncKind; - content_hash: string; - revision: number; - device_id: string; - deleted: boolean; - updated_at: string; -} 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/src/services/featureGates.ts b/src/services/featureGates.ts index 727a2e4..fe3f7f8 100644 --- a/src/services/featureGates.ts +++ b/src/services/featureGates.ts @@ -119,8 +119,8 @@ export const FREE_QUOTAS: Partial> = { */ const SYNC_PROVIDERS_BY_TIER: Record> = { free: ['postgres'], - sponsor: ['cloud', 'postgres', 'gist', 'onedrive', 'gdrive'], - singularity: ['cloud', 'postgres', 'gist', 'onedrive', 'gdrive'], + sponsor: ['cloud', 'postgres'], + singularity: ['cloud', 'postgres'], }; export function allowedSyncProviders(): ReadonlyArray { diff --git a/templates/settings-hub/index.html b/templates/settings-hub/index.html index e9907e0..cf766e8 100644 --- a/templates/settings-hub/index.html +++ b/templates/settings-hub/index.html @@ -7,6 +7,18 @@ PgStudio Settings + @@ -584,6 +596,10 @@

Visibility

Cloud Sync

Encrypted sync of connections, saved queries and notebooks

+
Loading sync status…
@@ -606,91 +622,209 @@

Cloud Sync is not set up