npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@open-webapp/project-sync

v0.1.10

Published

Lightweight project synchronization library for multi-project app state management. Built on top of `@open-webapp/drive-sync` for reliable storage and sync primitives.

Readme

@open-webapp/project-sync

Lightweight project synchronization library for multi-project app state management. Built on top of @open-webapp/drive-sync for reliable storage and sync primitives.

Owns orchestration (registry, per-project databases, sync scheduling, status) while apps keep their data shapes and merge logic. Supports any serialization, any merge strategy, any document set.

Features

  • Project registry — CRUD + unique-by-name (trimmed, case-insensitive) + ordered by creation time.
  • Per-project IndexedDB — Package manages handles, versions, lifecycle; app defines stores.
  • Sync orchestration — Interval + visibility-regain + debounce + single-flight per project + cross-tab leader election via BroadcastChannel.
  • Version-directed engine — Try-write-first (zero reads/merges on baseline match) + read-merge-write fallback (bounded at 3 attempts).
  • React hooks — Thin useSyncExternalStore wrappers; no components, no CSS.
  • Testing fakes — In-memory fakes + exported contract suites for app-side merge verification.

Installation

npm install @open-webapp/project-sync @open-webapp/drive-sync idb

Quick start

import { createProjectSync } from '@open-webapp/project-sync';
import { createDriveSync } from '@open-webapp/drive-sync';

// Step 1: Create a shared drive-sync instance (caller-owned)
const drive = createDriveSync({
  appId: 'my-app',
  clientId: GOOGLE_CLIENT_ID,
  folderPath: ['OpenWebApp', 'My App'],
});

// Step 2: Create the project-sync instance
const app = createProjectSync({
  drive,
  appName: 'My App',
  registryDbName: 'my-app-registry',
  data: {
    version: 1,
    upgrade(db, oldV, newV, tx) {
      if (oldV < 1) {
        db.createObjectStore('documents', { keyPath: 'id' });
      }
    },
  },
  documents: (project) => [
    {
      key: 'primary',
      name: 'state.json',
      mimeType: 'application/json',
      readLocal() { /* ... */ },
      writeLocal(merged) { /* ... */ },
      merge(local, remote) { /* ... */ },
    },
  ],
  interval: 5 * 60 * 1000, // 5 minutes, or null for manual
});

// Step 3: Connect, create/select a project, and start syncing
await app.connection.connect();
const project = await app.projects.create('My First Project');
await app.projects.setActive(project.id);
app.sync.start();

// Subscribe to status updates
const unsubscribe = app.subscribe(({ phase, lastSyncedAt, error }) => {
  console.log(`Sync phase: ${phase}, last synced: ${lastSyncedAt}, error: ${error}`);
});

Usage patterns by app shape

Pattern 1: Simple JSON state (union merge with local-wins collision)

Notesdiary's model — filter rules stored as JSON, merged by union of rule ids:

interface Entry {
  id: string;
  timestamp: number;
  text: string;
}

interface Rule {
  id: string;
  name: string;
  filename: string;
}

const app = createProjectSync({
  drive,
  appName: 'Notes Diary',
  registryDbName: 'notes-diary-registry',
  data: {
    version: 2,
    upgrade(db, oldV, newV, tx) {
      if (oldV < 1) {
        const entriesStore = db.createObjectStore('entries', { keyPath: 'id' });
        entriesStore.createIndex('by-date', 'timestamp');
      }
      if (oldV < 2) {
        db.createObjectStore('meta'); // for filter rules, drive metadata
      }
    },
  },
  documents: (project) => {
    // Read filter rules from IDB; each rule is one document
    const meta = /* read from project's db */;
    return meta.filterRules.map((rule) => ({
      key: rule.id,
      name: `${rule.filename}.json`, // Ensure .json extension
      mimeType: 'application/json',
      async readLocal() {
        const tx = db.transaction('entries');
        const entries = await tx.store.getAll();
        // Filter entries by this rule's criteria, return JSON
        return JSON.stringify(filtered);
      },
      async writeLocal(merged) {
        const data = JSON.parse(merged);
        // Upsert entries into 'entries' store
        await db.put('entries', ...data);
      },
      async merge(local, remote) {
        const localEntries = local ? JSON.parse(local) : {};
        const remoteEntries = remote ? JSON.parse(remote) : {};
        // Union by id; local wins on collision
        const merged = { ...remoteEntries, ...localEntries };
        return { merged: JSON.stringify(merged), conflicts: [] };
      },
    }));
  },
  interval: 5 * 60 * 1000,
});

Pattern 2: Encrypted state (remote-replace-or-local-wins)

Portfolio's model — single encrypted document, merged by decrypting both and choosing winner:

interface AppState {
  positions: Record<string, number>;
  selections: Record<string, string[]>;
}

const app = createProjectSync({
  drive,
  appName: 'Portfolio',
  registryDbName: 'portfolio-registry',
  data: {
    version: 1,
    upgrade(db, oldV, newV, tx) {
      if (oldV < 1) {
        db.createObjectStore('app_state');
      }
    },
  },
  documents: (project) => [
    {
      key: 'app-state',
      name: 'portfolio-state.json',
      mimeType: 'application/octet-stream',
      async readLocal() {
        const state = await db.get('app_state', 'current');
        if (!state) return null;
        // Encrypt to Uint8Array envelope
        const encrypted = await encryptState(state, userKey);
        return encrypted;
      },
      async writeLocal(merged) {
        // Decrypt Uint8Array
        const decrypted = await decryptState(merged, userKey);
        await db.put('app_state', decrypted, 'current');
      },
      async merge(local, remote) {
        // Decrypt both (or null if missing)
        const localState = local ? await decryptState(local, userKey) : null;
        const remoteState = remote ? await decryptState(remote, userKey) : null;
        // Remote-replace-or-local-wins: if remote exists and is newer, use it; else local
        const merged = (remoteState && remoteState.timestamp > localState?.timestamp)
          ? remoteState
          : localState || remoteState;
        return { merged, conflicts: [] };
      },
    },
  ],
  interval: 5 * 60 * 1000,
});

Pattern 3: Tabular data with conflict resolution (CSV merge)

Planning's model — tasks + milestones as CSV with conflict dialog:

interface Task {
  id: string;
  name: string;
  completed: boolean;
}

const app = createProjectSync({
  drive,
  appName: 'Planning',
  registryDbName: 'planning-registry',
  data: {
    version: 1,
    upgrade(db, oldV, newV, tx) {
      if (oldV < 1) {
        db.createObjectStore('tasks', { keyPath: 'id' });
        db.createObjectStore('milestones', { keyPath: 'id' });
      }
    },
  },
  documents: (project) => [
    {
      key: 'tasks',
      name: 'tasks.csv',
      mimeType: 'text/csv',
      async readLocal() {
        const tasks = await db.getAll('tasks');
        // Serialize to CSV: id,name,completed
        return tasks.map(t => `${t.id},${t.name},${t.completed}`).join('\n');
      },
      async writeLocal(merged) {
        const lines = (merged as string).split('\n');
        const tasks = lines.map((line) => {
          const [id, name, completed] = line.split(',');
          return { id, name, completed: completed === 'true' };
        });
        const tx = db.transaction('tasks', 'readwrite');
        for (const task of tasks) await tx.store.put(task);
      },
      async merge(local, remote) {
        const localTasks = local ? parseCSV(local) : {};
        const remoteTasks = remote ? parseCSV(remote) : {};
        
        // Simple merge with conflict detection
        const merged = { ...localTasks };
        const conflicts = [];
        
        for (const [id, remoteTask] of Object.entries(remoteTasks)) {
          if (!localTasks[id]) {
            merged[id] = remoteTask; // Remote-only, take it
          } else if (localTasks[id].modified > remoteTask.modified) {
            // Local is newer, keep it (local wins)
            conflicts.push({ id, localTask: localTasks[id], remoteTask });
          } else {
            // Remote is newer or same, take remote
            merged[id] = remoteTask;
          }
        }
        
        return {
          merged: toCSV(merged),
          conflicts, // Surfaced to UI for user resolution
        };
      },
    },
  ],
  interval: 5 * 60 * 1000,
});

React Integration

import { ProjectSyncProvider, useProjects, useActiveProject, useSyncStatus } from '@open-webapp/project-sync/react';

export function App() {
  const [app] = useState(() => createProjectSync(/* ... */));
  
  return (
    <ProjectSyncProvider instance={app}>
      <ProjectList />
      <SyncStatus />
    </ProjectSyncProvider>
  );
}

function ProjectList() {
  const projects = useProjects(); // ProjectSync<any>[]
  const activeProject = useActiveProject();
  const { status } = useSyncStatus();
  
  return (
    <div>
      {projects.map((p) => (
        <button
          key={p.id}
          onClick={() => app.projects.setActive(p.id)}
          style={{ fontWeight: p.id === activeProject?.id ? 'bold' : 'normal' }}
        >
          {p.name}
        </button>
      ))}
      <button onClick={() => app.sync.syncNow()}>Sync now</button>
    </div>
  );
}

function SyncStatus() {
  const status = useSyncStatus();
  
  return (
    <div>
      <span>Phase: {status.phase}</span>
      <span>Last synced: {status.lastSyncedAt ? new Date(status.lastSyncedAt).toLocaleString() : 'never'}</span>
      {status.error && <span style={{ color: 'red' }}>Error: {status.error.message}</span>}
      {status.needsReauth && <span>Re-authentication required</span>}
    </div>
  );
}

Testing

Use fakes for unit tests (no network, no IndexedDB required):

import { createProjectSyncFake } from '@open-webapp/project-sync/testing';

const app = createProjectSyncFake();

// Use like the real instance, but all in-memory
const project = await app.projects.create('Test Project');
await app.projects.setActive(project.id);

// Verify merge logic
import { describeMergeContract } from '@open-webapp/project-sync/testing';

describeMergeContract(
  myMergeFunction,
  {
    local: '{"a":1}',
    remote: '{"b":2}',
  },
  {
    expectedMerged: '{"a":1,"b":2}',
    expectedConflicts: [],
  }
);

Core concepts

  • Adapter-based — Package owns plumbing (registry, database lifecycle, sync scheduling); apps own data shapes and merge logic.
  • Names are truth — Folder and file names are resolved by name, not cached id. IDs are optimizations, not the source of truth.
  • Try-write-first — Syncs attempt a direct write (if the cached baseline matches Drive). Only on remote change does the engine read-merge-write.
  • Bounded conflict — Conflicts are bounded at 3 write attempts. Unresolvable conflicts throw an error (never silent data loss).
  • One project-sync per app — Each app owns one instance. Apps coordinate projects via the registry, not via separate instances.

For more

  • SPEC.md — All 36 resolved design decisions, storage layout, known limitations, rejected designs.
  • Types (src/types.ts) — Complete interface definitions with comments.
  • Testing guide — How to write merge tests + fakes.