Add local database mode for browser-only diagrams
Some checks failed
Node.js CI / build (push) Waiting to run
Build / build (push) Failing after 15m8s

- Add /db/local/:db path type that stores diagrams locally without syncing
- New diagrams now default to local storage (browser-only)
- Share button creates public copy when sharing local diagrams
- Add storage type badges (Local/Public/Private) in diagram manager
- Add GitHub Actions workflow for automated builds
- Block local- database requests at server with 404

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Michael Mainguy 2025-12-29 18:13:43 -06:00
parent a772372b2b
commit 1e174e81d3
11 changed files with 586 additions and 16 deletions

31
.github/workflows/build.yml vendored Normal file
View File

@ -0,0 +1,31 @@
name: Build
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build:
runs-on: linux_amd64
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Build Front End
run: npm run build
env:
NODE_OPTIONS: '--max-old-space-size=4096'
- name: Restart Server
run: |
# Kill existing process on port 3001
lsof -ti:3001 | xargs kill -9 2>/dev/null || true
# Start server in background with nohup
nohup npm run start > /tmp/immersive.log 2>&1 &

150
SHARING_PLAN.md Normal file
View File

@ -0,0 +1,150 @@
# Self-Hosted Diagram Sharing with Express-PouchDB
## Requirements (Confirmed)
- **Storage**: In-memory (ephemeral) - lost on server restart
- **Content**: Copy current diagram entities when creating share
- **Expiration**: No expiration - links work until server restart
- **Encryption**: None - keep it simple, anyone with link can access
## Architecture
```
┌─────────────────────────────────────────────────┐
│ Express Server (port 3001) │
├─────────────────────────────────────────────────┤
│ /api/share/* → Share management API │
│ /pouchdb/* → express-pouchdb (sync) │
│ /share/:uuid → Client share route │
│ /api/* → Existing API routes │
│ /* → Vite static files │
└─────────────────────────────────────────────────┘
└── In-Memory PouchDB (per share UUID)
```
## Implementation Steps
### Phase 1: Server-Side Setup
#### 1.1 Add Dependencies
```bash
npm install express-pouchdb pouchdb-adapter-memory
```
#### 1.2 Create PouchDB Server Service
**New file: `server/services/pouchdbServer.js`**
- Initialize PouchDB with memory adapter
- Track active share databases in a Map
- Export `getShareDB(shareId)`, `shareExists(shareId)`, `createPouchDBMiddleware()`
#### 1.3 Create Share API
**New file: `server/api/share.js`**
- `POST /api/share/create` - Generate UUID, create in-memory DB, copy entities
- `GET /api/share/:id/exists` - Check if share exists
- `GET /api/share/stats` - Debug endpoint for active shares
#### 1.4 Update API Router
**Edit: `server/api/index.js`**
- Add `import shareRouter from "./share.js"`
- Mount at `router.use("/share", shareRouter)`
#### 1.5 Mount Express-PouchDB
**Edit: `server.js`**
- Import `createPouchDBMiddleware` from pouchdbServer.js
- Mount at `app.use("/pouchdb", createPouchDBMiddleware())`
---
### Phase 2: Client-Side Integration
#### 2.1 Update URL Parsing
**Edit: `src/util/functions/getPath.ts`**
- Add `getPathInfo()` function returning `{ dbName, isShare, shareId }`
- Detect `/share/:uuid` pattern
#### 2.2 Update PouchDB Persistence Manager
**Edit: `src/integration/database/pouchdbPersistenceManager.ts`**
In `initLocal()`:
- Call `getPathInfo()` to detect share URLs
- If share: use `share-{uuid}` as local DB name, call `beginShareSync()`
Add new method `beginShareSync(shareId)`:
- Check share exists via `/api/share/:id/exists`
- Connect to `${origin}/pouchdb/share-${shareId}`
- Set up presence with `share-${shareId}` as DB name
- Begin live sync (no encryption)
#### 2.3 Add React Route
**Edit: `src/react/webRouter.tsx`**
- Add route `{ path: "/share/:uuid", element: <VrExperience isShare={true} /> }`
- No ProtectedRoute wrapper (public access)
#### 2.4 Add Share Button Handler
**Edit: `src/react/pages/vrExperience.tsx`**
- Add `isShare` prop
- Add `handleShare()` function:
1. Get all entities from local PouchDB
2. POST to `/api/share/create` with entities
3. Copy resulting URL to clipboard
4. Show confirmation
---
### Phase 3: Presence Integration
The WebSocket presence system already routes by database name. Since shares use `share-{uuid}` as the database name, presence works automatically.
**Edit: `server/server.js`** (WebSocket server)
- Update `originIsAllowed()` to allow localhost for development
---
## Files to Modify
| File | Action | Purpose |
|------|--------|---------|
| `package.json` | Edit | Add express-pouchdb, pouchdb-adapter-memory |
| `server.js` | Edit | Mount /pouchdb middleware |
| `server/api/index.js` | Edit | Add share router |
| `server/services/pouchdbServer.js` | Create | PouchDB memory initialization |
| `server/api/share.js` | Create | Share API endpoints |
| `server/server.js` | Edit | Allow localhost origins |
| `src/util/functions/getPath.ts` | Edit | Add getPathInfo() |
| `src/integration/database/pouchdbPersistenceManager.ts` | Edit | Add share sync logic |
| `src/react/webRouter.tsx` | Edit | Add /share/:uuid route |
| `src/react/pages/vrExperience.tsx` | Edit | Add share button handler |
| `src/util/featureConfig.ts` | Edit | Enable shareCollaborate feature |
---
## User Flow
### Creating a Share
1. User has a diagram open at `/db/public/mydiagram`
2. Clicks "Share" button
3. Client fetches all entities from local PouchDB
4. POSTs to `/api/share/create` with entities
5. Server creates in-memory DB, copies entities, returns UUID
6. Client copies `https://server.com/share/{uuid}` to clipboard
7. User shares link with collaborators
### Joining a Share
1. User navigates to `https://server.com/share/{uuid}`
2. React Router renders VrExperience with `isShare=true`
3. PouchdbPersistenceManager detects share URL
4. Checks `/api/share/:uuid/exists` - returns true
5. Creates local PouchDB `share-{uuid}`
6. Connects to `/pouchdb/share-{uuid}` for sync
7. Entities replicate to local, render in scene
8. Presence WebSocket connects with `share-{uuid}` as room
---
## Future Authentication (Not Implemented Now)
Structure allows easy addition later:
- express-pouchdb middleware can be wrapped with auth middleware
- Share API can require JWT/session tokens
- Could add password-protected shares
- Could add read-only vs read-write permissions

179
SYNC_PLAN.md Normal file
View File

@ -0,0 +1,179 @@
# Future Sync Strategy: Keeping Local and Public Clones in Sync
## Current State (v1)
- Sharing creates a **ONE-TIME COPY** from local to public
- Copies diverge independently after sharing
- No automatic sync between local and public versions
- Local diagrams are browser-only (IndexedDB via PouchDB)
- Public diagrams sync with server via express-pouchdb
### URL Scheme
| Route | Sync | Access | Status |
|-------|------|--------|--------|
| `/db/local/:id` | None | Browser-only | Implemented |
| `/db/public/:id` | Yes | Anyone | Implemented |
| `/db/private/:id` | Yes | Authorized users | Route only (no auth) |
## Future Options
### Option 1: Manual Push/Pull (Recommended for v2)
Add explicit user-triggered sync between local and public copies.
**Features:**
- "Push to Public" button - sends local changes to public copy
- "Pull from Public" button - gets public changes into local
- Track `lastSyncedAt` timestamp
- Show indicator when copies have diverged
- Conflict resolution: Last write wins (simple) or user choice (advanced)
**Pros:**
- User stays in control
- Clear mental model
- Simple to implement incrementally
**Cons:**
- Manual effort required
- Risk of forgetting to sync
### Option 2: Automatic Background Sync
Continuous bidirectional sync between local and public copies.
**Features:**
- Real-time sync like Google Docs
- Works across devices
- Offline-first with automatic merge
**Pros:**
- Seamless experience
- Always up to date
**Cons:**
- Complex conflict resolution (may need CRDTs)
- Higher performance overhead
- Harder to reason about state
### Option 3: Fork/Branch Model
One-way relationship: local is "draft", public is "published".
**Features:**
- Push only (local → public)
- No pull mechanism
- Public is the "source of truth" once published
**Pros:**
- Clear mental model
- No merge conflicts
- Simple implementation
**Cons:**
- Cannot incorporate public changes back to local
- Multiple people can't collaborate on draft
## Recommended Implementation (v2)
Implement **Option 1 (Manual Push/Pull)** as it provides the best balance of user control and simplicity.
### Data Model Changes
Add to diagram directory entry:
```typescript
interface DiagramEntry {
_id: string;
name: string;
description: string;
storageType: 'local' | 'public' | 'private';
createdAt: string;
// New fields for sync tracking
publicCopyId?: string; // ID of the public clone (if shared)
lastPushedAt?: string; // When changes were last pushed to public
lastPulledAt?: string; // When public changes were last pulled
publicVersion?: number; // Version number of public copy at last sync
}
```
### API Endpoints
```typescript
// Push local changes to public
POST /api/sync/push
Body: { localDbName: string, publicDbName: string }
Response: { success: boolean, documentsUpdated: number }
// Pull public changes to local
POST /api/sync/pull
Body: { localDbName: string, publicDbName: string }
Response: { success: boolean, documentsUpdated: number }
// Check if copies have diverged
GET /api/sync/status?local={localDbName}&public={publicDbName}
Response: {
diverged: boolean,
localChanges: number,
publicChanges: number,
lastSyncedAt: string
}
```
### UI Components
1. **Sync Status Indicator**
- Shows in header when viewing a local diagram that has a public copy
- Green check: In sync
- Orange dot: Changes pending
- Red warning: Conflicts detected
2. **Push/Pull Buttons**
- In hamburger menu under "Share" section
- "Push to Public" - shows confirmation with change count
- "Pull from Public" - shows confirmation with change count
3. **Divergence Warning Badge**
- Shows on diagram card in Manage Diagrams modal
- Indicates when local and public have diverged
4. **Conflict Resolution Dialog**
- Shows when both local and public have changes to same entity
- Options: Keep Local, Keep Public, Keep Both (creates duplicate)
### Implementation Phases
**Phase 1: Tracking**
- Add `publicCopyId` when sharing local → public
- Track sharing relationship in directory
**Phase 2: Push**
- Implement push from local to public
- Overwrite public with local changes
- Update `lastPushedAt` timestamp
**Phase 3: Pull**
- Implement pull from public to local
- Merge public changes into local
- Update `lastPulledAt` timestamp
**Phase 4: Status**
- Implement divergence detection
- Add UI indicators
- Show sync status in Manage Diagrams
**Phase 5: Conflict Resolution**
- Detect entity-level conflicts
- Show resolution dialog
- Allow user to choose resolution strategy
## Migration Notes
Existing diagrams without `storageType` are treated as `public` for backwards compatibility. When such diagrams are loaded, the UI should work correctly but sync tracking features won't be available until the diagram metadata is updated.
## Security Considerations
- Push/pull operations should validate that the user has access to both databases
- Public databases remain world-readable/writable
- Private database sync will require authentication tokens
- Rate limiting should be applied to sync operations

View File

@ -1,7 +1,7 @@
{
"name": "immersive",
"private": true,
"version": "0.0.8-42",
"version": "0.0.8-43",
"type": "module",
"license": "MIT",
"engines": {

View File

@ -9,6 +9,7 @@
*
* Database naming patterns:
* / - Root endpoint, always allowed (server info)
* /local-{dbname} - Should never reach server (client-only), return 404
* /public-{dbname} - No auth required, anyone can read/write
* /private-{dbname} - Auth required
* /{dbname} - Treated as private by default
@ -24,6 +25,15 @@ export function dbAuthMiddleware(req, res, next) {
return next();
}
// Local databases should never reach the server (they're browser-only)
if (dbName.startsWith('local-')) {
console.log(`[DB Auth] Local database access rejected: ${req.method} ${req.path}`);
return res.status(404).json({
error: 'not_found',
reason: 'Local databases are browser-only and do not sync to server'
});
}
// Check if this is a public database (name starts with 'public-')
const isPublic = dbName.startsWith('public-');

View File

@ -5,7 +5,7 @@ import {DiagramEventObserverMask} from "../../diagram/types/diagramEventObserver
import log, {Logger} from "loglevel";
import PouchDB from 'pouchdb';
import {importDiagramFromJSON, DiagramExport} from "../../util/functions/exportDiagramAsJSON";
import {isPublicPath, getRemoteDbPath} from "../../util/functions/getPath";
import {getDbType, getRemoteDbPath, shouldSync} from "../../util/functions/getPath";
export class PouchData {
public readonly onDBEntityUpdateObservable: Observable<DiagramEntity> = new Observable<DiagramEntity>();
@ -26,14 +26,25 @@ export class PouchData {
}
/**
* Initialize sync with remote express-pouchdb for public databases
* Initialize sync with remote express-pouchdb for public/private databases
* Local databases do not sync
*/
private initSync() {
const dbType = getDbType();
const remoteDbPath = getRemoteDbPath();
const isPublic = isPublicPath();
if (!remoteDbPath || !isPublic) {
this._logger.debug('[Sync] Not a public path, skipping remote sync');
if (dbType === 'local') {
this._logger.debug('[Sync] Local database - no remote sync');
return;
}
if (dbType === 'private') {
this._logger.info('[Sync] Private database sync not yet implemented');
return;
}
if (!remoteDbPath || !shouldSync()) {
this._logger.debug('[Sync] No remote path or sync disabled, skipping');
return;
}
@ -186,4 +197,52 @@ export class PouchData {
this._logger.warn('CONFLICTS!', doc._conflicts);
}
}
/**
* Copy all documents from this database to a new public database.
* Used when sharing a local diagram to make it publicly accessible.
* @param newDbName - The name for the new public database
* @returns The URL path to the new public diagram
*/
public async copyToPublic(newDbName: string): Promise<string> {
this._logger.info(`[Copy] Starting copy to public-${newDbName}`);
// Create the remote public database
const remoteUrl = `${window.location.origin}/pouchdb/public-${newDbName}`;
const remoteDb = new PouchDB(remoteUrl);
try {
// Get all docs from local database
const allDocs = await this._db.allDocs({ include_docs: true });
this._logger.debug(`[Copy] Found ${allDocs.rows.length} documents to copy`);
// Copy each document to the remote database
for (const row of allDocs.rows) {
if (row.doc) {
// Remove PouchDB internal fields for clean insert
const { _rev, ...docWithoutRev } = row.doc;
try {
await remoteDb.put(docWithoutRev);
} catch (err) {
// Document might already exist if this is a retry
this._logger.warn(`[Copy] Failed to copy doc ${row.id}:`, err);
}
}
}
this._logger.info(`[Copy] Successfully copied ${allDocs.rows.length} documents`);
return `/db/public/${newDbName}`;
} catch (err) {
this._logger.error('[Copy] Error copying to public:', err);
throw err;
}
}
/**
* Get all documents in the database (for export/copy operations)
*/
public async getAllDocs(): Promise<any[]> {
const result = await this._db.allDocs({ include_docs: true });
return result.rows.map(row => row.doc).filter(doc => doc && doc.id !== 'metadata');
}
}

View File

@ -7,11 +7,13 @@ import {useFeatureState} from "../hooks/useFeatures";
import ComingSoonBadge from "../components/ComingSoonBadge";
import UpgradeBadge from "../components/UpgradeBadge";
import {useAuth0} from "@auth0/auth0-react";
import {useNavigate} from "react-router-dom";
export default function CreateDiagramModal({createOpened, closeCreate}) {
const logger = log.getLogger('createDiagramModal');
const db = usePouch();
const { loginWithRedirect } = useAuth0();
const navigate = useNavigate();
// Feature flags
const privateDesignsState = useFeatureState('privateDesigns');
@ -43,7 +45,14 @@ export default function CreateDiagramModal({createOpened, closeCreate}) {
logger.warn('cannot find directory', err);
}
const id = 'diagram-' + v4();
const newDiagram = {...diagram, _id: id, type: 'diagram'};
// All new diagrams start as local (browser-only)
const newDiagram = {
...diagram,
_id: id,
type: 'diagram',
storageType: 'local',
createdAt: new Date().toISOString()
};
if (!doc) {
await db.put({_id: 'directory', diagrams: [newDiagram], type: 'directory'});
} else {
@ -56,6 +65,8 @@ export default function CreateDiagramModal({createOpened, closeCreate}) {
await db.put(doc);
}
closeCreate();
// Navigate to the local diagram
navigate(`/db/local/${id}`);
}
return (

View File

@ -1,4 +1,4 @@
import {Button, Card, Container, Group, Modal, Paper, SimpleGrid, Stack} from "@mantine/core";
import {Badge, Button, Card, Container, Group, Modal, Paper, SimpleGrid, Stack} from "@mantine/core";
import React from "react";
import {useDoc, usePouch} from "use-pouchdb";
import {IconTrash, IconDownload} from "@tabler/icons-react";
@ -7,6 +7,26 @@ import log from "loglevel";
import {useFeatureLimit} from "../hooks/useFeatures";
import {exportDiagramAsJSON} from "../../util/functions/exportDiagramAsJSON";
// Helper to get the correct URL path based on storage type
function getDiagramPath(diagram: any): string {
const storageType = diagram.storageType || 'public'; // Default to public for backwards compat
return `/db/${storageType}/${diagram._id}`;
}
// Helper to get badge color and text for storage type
function getStorageTypeBadge(storageType: string): { color: string; label: string } {
switch (storageType) {
case 'local':
return { color: 'gray', label: 'Local' };
case 'public':
return { color: 'green', label: 'Public' };
case 'private':
return { color: 'blue', label: 'Private' };
default:
return { color: 'green', label: 'Public' }; // Default for backwards compat
}
}
export default function ManageDiagramsModal({openCreate, manageOpened, closeManage}) {
const logger = log.getLogger('manageDiagramsModal');
const {doc: diagram, error} = useDoc('directory', {}, {_id: 'directory', diagrams: []});
@ -34,10 +54,16 @@ export default function ManageDiagramsModal({openCreate, manageOpened, closeMana
};
const cards = diagrams.map((diagram) => {
const badgeInfo = getStorageTypeBadge(diagram.storageType);
const diagramPath = getDiagramPath(diagram);
return (
<Card key={diagram._id}>
<Card.Section>
<Container w={512} h={64}>{diagram.name}</Container>
<Group justify="space-between" p="xs">
<Container w={400} h={64}>{diagram.name}</Container>
<Badge color={badgeInfo.color} size="sm">{badgeInfo.label}</Badge>
</Group>
</Card.Section>
<Card.Section>
<Container w={512} h={128}>
@ -46,7 +72,7 @@ export default function ManageDiagramsModal({openCreate, manageOpened, closeMana
</Card.Section>
<Card.Section>
<Group justify="space-evenly">
<Button component={Link} key="examples" to={"/db/public/" + diagram._id} p={5} c="myColor"
<Button component={Link} key="examples" to={diagramPath} p={5} c="myColor"
bg="none">Select</Button>
<Button

View File

@ -20,6 +20,9 @@ import VREntryPrompt from "../components/VREntryPrompt";
import ComingSoonBadge from "../components/ComingSoonBadge";
import UpgradeBadge from "../components/UpgradeBadge";
import ChatPanel from "../components/ChatPanel";
import {getDbType} from "../../util/functions/getPath";
import PouchDB from 'pouchdb';
import {v4} from "uuid";
let vrApp: VrApp = null;
@ -122,10 +125,76 @@ export default function VrExperience() {
const [showVRPrompt, setShowVRPrompt] = useState(false);
const [chatOpen, setChatOpen] = useState(!isMobileVRDevice()); // Show chat by default on desktop
// Handle share: simply copy current URL to clipboard
// Handle share based on current database type:
// - Public: copy current URL to clipboard
// - Local: create one-time copy to public, share new URL
// - Private: show "not yet supported" message
const handleShare = async () => {
const dbType = getDbType();
logger.info(`[Share] Sharing diagram - type: ${dbType}, dbName: ${dbName}`);
if (dbType === 'private') {
alert('Sharing private diagrams is not yet supported.\n\nPrivate diagram sharing with access control is coming soon!');
return;
}
if (dbType === 'local') {
// Create a one-time copy to public
logger.info('[Share] Creating public copy of local diagram...');
try {
// Generate new ID for the public copy
const publicDbName = 'diagram-' + v4();
const localDb = new PouchDB(dbName);
const remoteUrl = `${window.location.origin}/pouchdb/public-${publicDbName}`;
const remoteDb = new PouchDB(remoteUrl);
// Get all docs from local database
const allDocs = await localDb.allDocs({ include_docs: true });
logger.debug(`[Share] Found ${allDocs.rows.length} documents to copy`);
// Copy each document to the remote database
for (const row of allDocs.rows) {
if (row.doc) {
// Remove PouchDB internal fields for clean insert
const { _rev, ...docWithoutRev } = row.doc as any;
try {
await remoteDb.put(docWithoutRev);
} catch (err) {
logger.warn(`[Share] Failed to copy doc ${row.id}:`, err);
}
}
}
const publicUrl = `${window.location.origin}/db/public/${publicDbName}`;
logger.info(`[Share] Public copy created at: ${publicUrl}`);
// Copy URL to clipboard
let copied = false;
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(publicUrl);
copied = true;
}
} catch (clipboardError) {
logger.warn('Clipboard API failed:', clipboardError);
}
if (copied) {
alert(`Public copy created!\n\nURL copied to clipboard:\n${publicUrl}\n\nNote: Your local diagram remains unchanged. The public copy will diverge independently.`);
} else {
prompt('Public copy created! Share URL (copy manually):', publicUrl);
}
} catch (err) {
logger.error('[Share] Failed to create public copy:', err);
alert('Failed to create public copy. Please try again.');
}
return;
}
// Public diagram - just copy current URL
const shareUrl = window.location.href;
logger.info(`Sharing URL: ${shareUrl}`);
logger.info(`[Share] Sharing public URL: ${shareUrl}`);
// Try to copy URL to clipboard with fallback
let copied = false;

View File

@ -36,6 +36,11 @@ export const webRouter = createBrowserRouter([
<Pricing/>
</ProtectedRoute>
)
}, {
path: "/db/local/:db",
element: (
<VrExperience/> // No ProtectedRoute - works offline, browser-only
)
}, {
path: "/db/public/:db",
element: (

View File

@ -1,6 +1,6 @@
export function getPath(): string {
const path = window.location.pathname.split('/');
// Handle /db/public/:db or /db/private/:db patterns
// Handle /db/local/:db, /db/public/:db, or /db/private/:db patterns
if (path.length >= 4 && path[1] === 'db') {
return path[3];
}
@ -11,37 +11,67 @@ export function getPath(): string {
return null;
}
/**
* Check if the current path is a local database (no sync)
* Local paths: /db/local/:db
*/
export function isLocalPath(): boolean {
const path = window.location.pathname.split('/');
return path.length >= 3 && path[1] === 'db' && path[2] === 'local';
}
/**
* Check if the current path is a public database
* Public paths: /db/public/:db
* Private paths: /db/private/:db
*/
export function isPublicPath(): boolean {
const path = window.location.pathname.split('/');
return path.length >= 3 && path[1] === 'db' && path[2] === 'public';
}
/**
* Check if the current path is a private database
* Private paths: /db/private/:db
*/
export function isPrivatePath(): boolean {
const path = window.location.pathname.split('/');
return path.length >= 3 && path[1] === 'db' && path[2] === 'private';
}
/**
* Get the database type from the current path
*/
export function getDbType(): 'public' | 'private' | null {
export function getDbType(): 'local' | 'public' | 'private' | null {
const path = window.location.pathname.split('/');
if (path.length >= 3 && path[1] === 'db') {
if (path[2] === 'local') return 'local';
if (path[2] === 'public') return 'public';
if (path[2] === 'private') return 'private';
}
return null;
}
/**
* Check if the current diagram should sync with the server
* Only public and private paths should sync; local is browser-only
*/
export function shouldSync(): boolean {
const dbType = getDbType();
return dbType === 'public' || dbType === 'private';
}
/**
* Get the full database path for PouchDB sync
* Returns: public-{dbname} or private-{dbname}
* Returns null for local paths (no sync)
* Uses dash separator instead of slash for express-pouchdb compatibility
*/
export function getRemoteDbPath(): string | null {
const path = window.location.pathname.split('/');
if (path.length >= 4 && path[1] === 'db') {
const type = path[2]; // 'public' or 'private'
const type = path[2]; // 'local', 'public', or 'private'
// Local paths don't sync
if (type === 'local') return null;
const dbName = path[3];
return `${type}-${dbName}`;
}