Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
cfd4203
test(perf): A0.1 large-manuscript performance harness
qnbs Jun 15, 2026
0c3f474
feat(local-first): B0.1 CRDT shadow-doc PoC + decision-gate proofs
qnbs Jun 15, 2026
2ada64b
docs(adr): ADR-0008 — local-first data model (Y.Doc as source of truth)
qnbs Jun 15, 2026
e502c06
feat(local-first): B1.1 binding seam — incremental shadow write-through
qnbs Jun 15, 2026
28815f1
feat(local-first): B1.1 wiring — enableLocalFirstSync flag + shadow s…
qnbs Jun 15, 2026
9ff710e
test(local-first): deterministic docPersistence waits + clarify IDB b…
qnbs Jun 15, 2026
87fc1eb
test(local-first): guarantee provider teardown via try/finally (CodeA…
qnbs Jun 15, 2026
ea2acbb
fix(local-first): docBinding + shadow-sync review fixes (CodeAnt #140)
qnbs Jun 15, 2026
9ee0e9d
fix(local-first): entity ids order, test isolation, post-await flag g…
qnbs Jun 15, 2026
0fffdac
fix(local-first): full-section verify, fixture goal, test cleanup (Co…
qnbs Jun 15, 2026
3bcc7d1
fix(local-first): private-mode IDB fallback + cold-start init (CodeAn…
qnbs Jun 15, 2026
14c8ff2
test(perf): exact-size fixture generation + constant-size bench edit …
qnbs Jun 15, 2026
86bf96e
fix(local-first): handle y-indexeddb whenSynced async rejection (#140…
qnbs Jun 15, 2026
6c308b3
fix(local-first): verify stale meta + write entities in canonical ord…
qnbs Jun 16, 2026
54857fb
fix(local-first): encryption-aware persistence + harden teardown/cont…
qnbs Jun 16, 2026
715eeb3
fix(local-first): re-check encryption on handle reuse + prove persist…
qnbs Jun 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ dist-ssr
*.local
coverage/
reports/
tests/bench/baseline/

# Editor directories and files
.vscode/*
Expand Down
8 changes: 7 additions & 1 deletion App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@ const CopilotLauncher = lazy(() =>
import('./components/copilot/CopilotLauncher').then((m) => ({ default: m.CopilotLauncher })),
);

import { initAdaptiveAiOnStartup, initWorkerBusOnStartup } from './app/listenerMiddleware';
import {
initAdaptiveAiOnStartup,
initLocalFirstSyncOnStartup,
initWorkerBusOnStartup,
} from './app/listenerMiddleware';
import { Header } from './components/Header';
import { Sidebar } from './components/Sidebar';
import { IdbUnlockModal } from './components/settings/IdbUnlockModal';
Expand Down Expand Up @@ -321,6 +325,8 @@ const App: FC<AppProps> = ({ isNewUser }) => {
initAdaptiveAiOnStartup(featureFlags.enableAdaptiveAiEngine);
// QNBS-v3: Phase 2 — init WorkerBus v2 on cold start if already enabled in persisted state
void initWorkerBusOnStartup(featureFlags.enableWorkerBusV2);
// QNBS-v3: B1.1 — init Local-First shadow sync on cold start if the flag is already on
void initLocalFirstSyncOnStartup(featureFlags.enableLocalFirstSync);
}, []);

// QNBS-v3: B-1 sentinel guard — async because IDB sentinel read is async.
Expand Down
155 changes: 155 additions & 0 deletions app/listenerMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { saveEnvelopeFromProjectData } from '../services/storageBackend';
import { storageService } from '../services/storageService';
import type { Character, StorySection, World } from '../types';
import type { AppDispatch, RootState } from './store';
import { appStoreRef } from './storeRef';

type ProjectStateWithHistory = {
present?: { data?: ProjectData };
Expand Down Expand Up @@ -565,6 +566,160 @@ listenerMiddleware.startListening({
},
});

// QNBS-v3: B1.1 — Local-First shadow sync (ADR-0008). When enableLocalFirstSync is on, mirror the
// authoritative Redux project into a per-project Yjs doc (+ y-indexeddb). Redux stays the source of
// truth: on any read-verify drift we self-heal via full re-projection and only log (never surface to
// the user). All local-first modules are dynamically imported so they stay out of the main bundle.
type LocalFirstHandle = {
projectId: string;
binding: import('../services/localFirst/docBinding').ProjectDocBinding;
persistence: import('../services/localFirst/docPersistence').DocPersistence;
};
let localFirstHandle: LocalFirstHandle | null = null;

// QNBS-v3 (CodeAnt): serialize all handle create/teardown so an overlapping sync run and an
// enable/disable run can't race on the shared module-global and leave the wrong handle active.
let localFirstLock: Promise<void> = Promise.resolve();
function withLocalFirstLock<T>(fn: () => Promise<T>): Promise<T> {
const run = localFirstLock.then(fn, fn);
localFirstLock = run.then(
() => undefined,
() => undefined,
);
return run;
}

function getLocalFirstHandle(project: ProjectData): Promise<LocalFirstHandle> {
return withLocalFirstLock(async () => {
const projectId = project.id ?? 'default';
const { isIdbEncryptionReady } = await import('../services/storage/storageEncryptionService');
if (localFirstHandle?.projectId === projectId) {
// QNBS-v3 (CodeAnt): the persistence backend (NOOP vs y-indexeddb) is chosen at handle
// creation. If at-rest encryption became active AFTER a plaintext-persisting handle was made,
// tear it down — wiping the plaintext already written — so no further plaintext is persisted.
if (isIdbEncryptionReady() && localFirstHandle.persistence.active) {
await localFirstHandle.persistence.clearData().catch(() => undefined);
await localFirstHandle.persistence.destroy().catch(() => undefined);
localFirstHandle = null;
} else {
return localFirstHandle;
}
} else if (localFirstHandle) {
// Project switched — tear down the previous handle before creating a new one.
await localFirstHandle.persistence.destroy().catch(() => undefined);
localFirstHandle = null;
}
const [
{ createBlankProjectDoc },
{ ProjectDocBinding },
{ persistProjectDoc, NOOP_PERSISTENCE },
] = await Promise.all([
import('../services/localFirst/projectDoc'),
import('../services/localFirst/docBinding'),
import('../services/localFirst/docPersistence'),
]);
const doc = createBlankProjectDoc();
// QNBS-v3 (CodeAnt): never write a PLAINTEXT shadow copy to y-indexeddb when at-rest encryption
// is active — the local-first doc is not encrypted yet. Keep it in-memory only so the privacy
// guarantee holds; shadow-sync still validates against Redux (SoT).
const persistence = isIdbEncryptionReady()
? NOOP_PERSISTENCE
: persistProjectDoc(projectId, doc);
await persistence.whenSynced; // load any persisted state first …
const binding = new ProjectDocBinding(project, doc); // … then project Redux over it (SoT wins)
Comment thread
qnbs marked this conversation as resolved.
localFirstHandle = { projectId, binding, persistence };
return localFirstHandle;
});
}

function teardownLocalFirst(): Promise<void> {
return withLocalFirstLock(async () => {
const handle = localFirstHandle;
localFirstHandle = null;
if (handle) await handle.persistence.destroy().catch(() => undefined);
});
}

// Shared shadow-sync run — used by both the debounced edit listener and the OFF→ON enable listener.
// `stillEnabled` is re-evaluated AFTER the async handle init so a sync that was in flight when the
// user disabled the flag aborts instead of resurrecting a torn-down binding.
async function runLocalFirstShadowSync(
state: RootState,
stillEnabled: () => boolean,
): Promise<void> {
if (state.featureFlags?.enableLocalFirstSync !== true) return;
const projectState = state.project as ProjectStateWithHistory;
const presentData = projectState.present?.data ?? projectState.data;
if (!presentData || presentData.title === undefined) return;
try {
const handle = await getLocalFirstHandle(presentData);
// QNBS-v3 (CodeAnt): flag may have flipped off during the await — re-check before mutating.
if (!stillEnabled()) return;
handle.binding.syncFromProject(presentData);
const result = handle.binding.verify(presentData);
if (!result.ok) {
// Shadow phase: never affect the user. Log a count (no ids/content → no PII) and self-heal.
logger.warn('Local-First shadow drift — self-healing via re-projection', {
mismatchCount: result.mismatches.length,
});
handle.binding.reproject(presentData);
}
} catch (err) {
logger.warn('Local-First shadow sync failed (non-critical):', err);
}
}

addDebouncedListener(
(curr, prev) =>
curr.featureFlags?.enableLocalFirstSync === true &&
curr.project?.present !== prev.project?.present,
Comment thread
qnbs marked this conversation as resolved.
1200,
async (api) => {
await runLocalFirstShadowSync(
api.getState(),
() => api.getState().featureFlags?.enableLocalFirstSync === true,
);
},
);

// QNBS-v3 (CodeAnt): OFF→ON must perform an immediate initial projection — otherwise the shadow doc
// stays uninitialized until the user happens to make another edit.
listenerMiddleware.startListening({
predicate: (_action, curr, prev) =>
(curr as RootState).featureFlags?.enableLocalFirstSync === true &&
(prev as RootState).featureFlags?.enableLocalFirstSync !== true,
effect: async (_action, listenerApi) => {
await runLocalFirstShadowSync(
listenerApi.getState() as RootState,
() => (listenerApi.getState() as RootState).featureFlags?.enableLocalFirstSync === true,
);
},
});
Comment thread
qnbs marked this conversation as resolved.

// Tear down the shadow binding when the flag is turned off.
listenerMiddleware.startListening({
predicate: (_action, curr, prev) =>
(curr as RootState).featureFlags?.enableLocalFirstSync !== true &&
(prev as RootState).featureFlags?.enableLocalFirstSync === true,
effect: async () => {
await teardownLocalFirst();
logger.info('Local-First shadow sync disabled — binding torn down');
},
});

// QNBS-v3 (CodeAnt): cold-start init — when enableLocalFirstSync is already true in persisted state,
// neither the edit listener nor the OFF→ON listener fires, so the shadow doc would stay uninitialized
// until the next edit. Call this once on mount (App.tsx) to run an initial projection + verify.
export async function initLocalFirstSyncOnStartup(enabled: boolean): Promise<void> {
if (!enabled) return;
const store = appStoreRef.current;
if (!store) return;
await runLocalFirstShadowSync(
store.getState(),
() => store.getState().featureFlags?.enableLocalFirstSync === true,
);
}

export const startAppListening = listenerMiddleware.startListening as TypedStartListening<
RootState,
AppDispatch
Expand Down
2 changes: 2 additions & 0 deletions components/settings/FeatureFlagsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ export const FeatureFlagsSection: FC = () => {
{ key: 'enableRustCompute', labelKey: 'settings.featureFlags.enableRustCompute' },
// QNBS-v3: Global AI Copilot — beginner-friendly in-app live assistant (ENABLE_GLOBAL_COPILOT).
{ key: 'enableGlobalCopilot', labelKey: 'settings.featureFlags.enableGlobalCopilot' },
// QNBS-v3: Local-First sync (shadow) — Yjs doc + y-indexeddb projection; Redux stays SoT (B1.1).
{ key: 'enableLocalFirstSync', labelKey: 'settings.featureFlags.enableLocalFirstSync' },
// QNBS-v3: enableIdbAtRestEncryption removed — toggling without passphrase setup blocks all users.
// Dedicated UI lives in Settings → Privacy (PrivacySection.tsx).
];
Expand Down
83 changes: 83 additions & 0 deletions docs/adr/0008-local-first-data-model.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# ADR 0008 — Local-first data model: Yjs document as source of truth

- **Status:** Accepted (decision made; migration staged behind a gate)
- **Date:** 2026-06-15
- **Deciders:** Maintainer + Claude Code
- **Context tags:** architecture, state, local-first, data-model, migration

## Context

StoryCraft is **offline-first** (encrypted IndexedDB + Tauri FS persistence, `redux-undo` history)
but **not local-first** in the CRDT sense. `services/collaborationService.ts` instantiates an
*ephemeral* `Y.Doc` only on `connect()` and exposes one `getSharedText('manuscript')` seam; the
manuscript's source of truth is a plain Redux object (`ProjectData`, `StorySection.content: string`).
The editor never reads or writes through Yjs — so multi-device sync, offline-merge, and conflict-free
collaboration are unbuilt, and the Yjs dependency is inert *as a data-model technology*.

[[0001-state-management-boundaries]] assigns persisted domain data (project, manuscript, characters,
world…) to Redux Toolkit + `redux-undo`. That was correct for a single-device serialization model,
but a serialization-shaped POJO is the wrong shape for fine-grained merge: two array/string LWW
replicas cannot converge a paragraph edited concurrently without losing a side.

A proof of concept (B0.1, strategic plan §5/§8) validated the alternative before committing:
`services/localFirst/projectDoc.ts` maps `ProjectData ↔ Y.Doc` and `tests/unit/localFirst/projectDoc.test.ts`
proves round-trip fidelity, **char-level concurrent same-section merge keeping both edits**,
section-level merge, and `Y.UndoManager` undo/redo. The gate passed.

## Decision

**One `Y.Doc` per project becomes the canonical store; Redux is demoted to a derived read-model.**

Schema (per project):

| Domain field | Yjs type | Notes |
|---|---|---|
| `manuscript` | `Y.Array<Y.Map>` | one map per section; order = array order |
| `StorySection.content` | `Y.Text` | char-level CRDT merge — the headline win |
| other section scalars | `Y.Map` keys | LWW per key |
| `characters`, `worlds` | `Y.Map<id, entity>` | `EntityState` rebuilt via the RTK adapters on read |
| everything else (`title`, `logline`, `outline`, goals…) | `meta` `Y.Map` | small, LWW |
| ephemeral UI (plotBoard viewport, copilot, voice, command palette…) | **stays Redux/Zustand** | **never** CRDT — [[0001-state-management-boundaries]] unchanged |

Supporting decisions:

- **Yjs, not Automerge.** Yjs is already vendored, security-reviewed, and transport-integrated (the
`packages/collab-transport` y-webrtc fork with RTCDataChannel E2E). Switching libraries would
discard a year of hardening for ergonomic gains that do not change the outcome.
- **`redux-undo` → `Y.UndoManager` at the flip.** Two undo models cannot both own the same truth; the
CRDT-native history becomes the single source once a project is on Yjs.
- **Redux selectors and component reads are unchanged.** A binding (`docBinding`) observes the doc and
dispatches a single `project/hydrateFromDoc`; the editor writes through Yjs transactions. This
preserves the entire existing UI and test surface — essential for an indie team.
- **The flip is gated and per-project, behind `enableLocalFirstSync` (off by default).** A project
that has flipped keeps a POJO backup until confirmed stable; the flip is a **one-way door** per
project, so it ships dark and is enabled only after the B0.1 gate + a Phase-1 CI read-verify both
pass. Persistence (`y-indexeddb` + Tauri update-log) and sync (file-based → P2P → optional relay)
layer on after the flip.

This ADR **supersedes the persistence / source-of-truth half** of [[0001-state-management-boundaries]]
for project *domain* data. It does **not** change the ephemeral-state half: transient UI stays in
Zustand/Redux exactly as before.

## Consequences

- **Positive:** true offline-merge and multi-device become possible on the existing E2E transport; a
single coherent edit history; char-level conflict resolution that a string model cannot provide;
the substrate (Yjs, crypto, worker bus) is already present.
- **Negative:** the migration is the highest-risk work item in the roadmap; flipping the canonical
store is irreversible per project; during the transition two mental models coexist (mitigated by the
shadow phase keeping `redux-undo` until the flip, and by this ADR).
- **Rejected — stay offline-only / single-device:** caps the product at "an excellent single-device
writer" when the parts for the local-first category leader already exist.
- **Rejected — shadow doc permanent (Redux stays SoT):** a derived Redux cannot merge; you get sync
plumbing without the conflict-free guarantee, and you maintain two histories forever.
- **Rejected — Automerge:** see above; discards vendored, security-reviewed Yjs + transport.

## References

- PoC: `services/localFirst/projectDoc.ts`, `tests/unit/localFirst/projectDoc.test.ts` (B0.1 gate)
- Baseline: A0.1 perf harness (`tests/bench/`) — the before/after gate for the migration
- Strategic plan: `/home/pc/.claude/plans/master-prompt-strategic-snazzy-duckling.md` (§3, §5, §7, §8)
- [[0001-state-management-boundaries]] (superseded in part), `services/collaborationService.ts`,
`packages/collab-transport`
- **0009 (planned)** — sync threat model (signaling metadata, E2E guarantees, opt-in posture)
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ than editing history.
| [0005](0005-webllm-worker-offload.md) | WebLLM inference offloaded to a dedicated WorkerBus v2 pool | Accepted |
| [0006](0006-superseded.md) | (reserved, never issued) | Superseded / void |
| [0007](0007-plugin-sandbox-model.md) | Plugin Sandbox Model | Accepted |
| [0008](0008-local-first-data-model.md) | Local-first data model: Yjs document as source of truth | Accepted |

**Format:** Context → Decision → Consequences (incl. rejected alternatives). Keep each ADR to one
decision. Link related records with `[[slug]]`.
9 changes: 9 additions & 0 deletions features/featureFlags/featureFlagsSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ export interface FeatureFlagsState {
enableRustCompute: boolean;
/** Global AI Copilot — beginner-friendly, context-aware, local-first in-app live assistant (default: true). */
enableGlobalCopilot: boolean;
/** Local-First sync (shadow) — mirror the project into a Yjs doc + y-indexeddb; Redux stays SoT (default: false). */
enableLocalFirstSync: boolean;
}

const FEATURE_FLAGS_STORAGE_KEY = 'storycraft-feature-flags';
Expand Down Expand Up @@ -81,6 +83,8 @@ const defaultFeatureFlagsState: FeatureFlagsState = {
enableRustCompute: true,
// QNBS-v3: global copilot off by default — ambient AI; user opt-in per privacy preference
enableGlobalCopilot: false,
// QNBS-v3: local-first sync off by default — experimental shadow projection (B1.1); Redux stays SoT
enableLocalFirstSync: false,
};

const loadFeatureFlagsState = (): FeatureFlagsState => {
Expand Down Expand Up @@ -188,6 +192,9 @@ const featureFlagsSlice = createSlice({
setEnableGlobalCopilot(state, action: PayloadAction<boolean>) {
state.enableGlobalCopilot = action.payload;
},
setEnableLocalFirstSync(state, action: PayloadAction<boolean>) {
state.enableLocalFirstSync = action.payload;
},
},
});

Expand Down Expand Up @@ -238,6 +245,8 @@ export const selectEnableRustCompute = (state: { featureFlags: FeatureFlagsState
state.featureFlags.enableRustCompute;
export const selectEnableGlobalCopilot = (state: { featureFlags: FeatureFlagsState }) =>
state.featureFlags.enableGlobalCopilot;
export const selectEnableLocalFirstSync = (state: { featureFlags: FeatureFlagsState }) =>
state.featureFlags.enableLocalFirstSync;

export const featureFlagsPersistenceMiddleware: Middleware<unknown, unknown> =
(storeAPI) => (next) => (action) => {
Expand Down
4 changes: 4 additions & 0 deletions hooks/useSettingsView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,10 @@ export const useSettingsView = () => {
case 'enableRustCompute':
dispatch(featureFlagsActions.setEnableRustCompute(Boolean(value)));
break;
// QNBS-v3: Local-First sync (shadow) — Yjs doc + y-indexeddb projection (B1.1).
case 'enableLocalFirstSync':
dispatch(featureFlagsActions.setEnableLocalFirstSync(Boolean(value)));
break;
// QNBS-v3: Global AI Copilot — beginner-friendly in-app live assistant.
case 'enableGlobalCopilot':
dispatch(featureFlagsActions.setEnableGlobalCopilot(Boolean(value)));
Expand Down
1 change: 1 addition & 0 deletions locales/ar/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,7 @@
"settings.featureFlags.enableDuckDbAnalytics": "تحليلات متقدّمة (قاعدة بيانات قصص سريعة)",
"settings.featureFlags.enableGlobalCopilot": "Global AI Copilot (beginner-friendly in-app live assistant)",
"settings.featureFlags.enableIdbAtRestEncryption": "تشفير IDB أثناء السكون ‏(AES-256-GCM) — فعّله عبر الإعدادات › الخصوصية",
"settings.featureFlags.enableLocalFirstSync": "Local-First sync (shadow — experimental)",
"settings.featureFlags.enableLoraAdapters": "استدلال مُكيِّف LoRA (تجريبي)",
"settings.featureFlags.enableMindMaps": "خرائط ذهنية مُحسَّنة",
"settings.featureFlags.enableObjectsGroups": "مخزون الأشياء والمجموعات",
Expand Down
1 change: 1 addition & 0 deletions locales/de/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,7 @@
"settings.featureFlags.enableDuckDbAnalytics": "Erweiterte Analysen (schnelle Story-Datenbank)",
"settings.featureFlags.enableGlobalCopilot": "Globaler KI-Copilot (einsteigerfreundlicher Live-Assistent in der App)",
"settings.featureFlags.enableIdbAtRestEncryption": "IDB-Ruheverschlüsselung (AES-256-GCM) — über Einstellungen › Datenschutz aktivieren",
"settings.featureFlags.enableLocalFirstSync": "Local-First-Synchronisierung (Schatten – experimentell)",
"settings.featureFlags.enableLoraAdapters": "LoRA-Adapter-Inferenz (experimentell)",
"settings.featureFlags.enableMindMaps": "Erweiterte Mindmaps",
"settings.featureFlags.enableObjectsGroups": "Objekte & Gruppen",
Expand Down
1 change: 1 addition & 0 deletions locales/el/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,7 @@
"settings.featureFlags.enableDuckDbAnalytics": "Προηγμένα αναλυτικά στοιχεία (βάση δεδομένων γρήγορης ιστορίας)",
"settings.featureFlags.enableGlobalCopilot": "Global AI Copilot (beginner-friendly in-app live assistant)",
"settings.featureFlags.enableIdbAtRestEncryption": "IDB at-rest encryption (AES-256-GCM) — enable via Ρυθμίσεις › Privacy",
"settings.featureFlags.enableLocalFirstSync": "Συγχρονισμός local-first (σκιά — πειραματικό)",
"settings.featureFlags.enableLoraAdapters": "Συμπεράσματα προσαρμογέα LoRA (πειραματικό)",
"settings.featureFlags.enableMindMaps": "Βελτιωμένοι χάρτες μυαλού",
"settings.featureFlags.enableObjectsGroups": "Απογραφή αντικειμένων & ομάδων",
Expand Down
Loading
Loading