audit fixes, repo restructure, and documentation

Soundness and performance audit (17 fixes):
- See AUDIT.md for full details and @claude comments in code

Repo restructure:
- Inline json-ptr and json-patch submodules as regular directories
- Remove cbor submodule, replace serde_cbor with ciborium
- Rename patch-db/ -> core/, patch-db-macro/ -> macro/,
  patch-db-macro-internals/ -> macro-internals/, patch-db-util/ -> util/
- Purge upstream CI/CD, bench, and release cruft from json-patch
- Remove .gitmodules

Test fixes:
- Fix proptest doesnt_crash (unique file paths, proper close/cleanup)
- Add PatchDb::close() for clean teardown

Documentation:
- Add README.md, ARCHITECTURE.md, CONTRIBUTING.md, CLAUDE.md, AUDIT.md
- Add TSDocs to TypeScript client exports

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt Hill
2026-02-23 19:06:42 -07:00
parent 05c93290c7
commit 86b0768bbb
46 changed files with 5744 additions and 95 deletions

View File

@@ -6,7 +6,6 @@ import {
Subscription,
switchMap,
take,
withLatestFrom,
} from 'rxjs'
import {
applyOperation,
@@ -15,6 +14,29 @@ import {
pathFromArray,
} from './json-patch-lib'
/**
* Observable database client backed by RFC 6902 JSON Patches.
*
* Consumes a stream of {@link Update}s (either full {@link Dump}s or incremental
* {@link Revision}s) from a server, maintains a local cache, and exposes reactive
* `watch$()` observables for any subtree of the document.
*
* @typeParam T - The shape of the root document.
*
* @example
* ```ts
* interface AppState {
* users: { [id: string]: { name: string } }
* settings: { theme: string }
* }
*
* const db = new PatchDB<AppState>(source$)
* db.start()
*
* // Type-safe deep watching (up to 6 levels)
* db.watch$('settings', 'theme').subscribe(theme => console.log(theme))
* ```
*/
export class PatchDB<T extends { [key: string]: any }> {
private sub: Subscription | null = null
private watchedNodes: {
@@ -24,6 +46,10 @@ export class PatchDB<T extends { [key: string]: any }> {
}
} = {}
/**
* @param source$ - Observable delivering batches of updates from the server.
* @param cache$ - Optional initial cache. Defaults to an empty document at revision 0.
*/
constructor(
private readonly source$: Observable<Update<T>[]>,
private readonly cache$ = new BehaviorSubject<Dump<T>>({
@@ -32,16 +58,27 @@ export class PatchDB<T extends { [key: string]: any }> {
}),
) {}
/**
* Begin listening to the source observable and applying updates.
* Calling `start()` when already started is a no-op.
*/
start() {
if (this.sub) return
this.sub = this.source$
.pipe(withLatestFrom(this.cache$))
.subscribe(([updates, cache]) => {
this.proccessUpdates(updates, cache)
})
// @claude fix #14: Previously used `source$.pipe(withLatestFrom(cache$))`.
// Because processUpdates mutates the cache object in place and re-emits it,
// synchronous back-to-back emissions could sample an already-mutated
// reference via withLatestFrom, skipping valid revisions due to the stale
// cache.id check. Reading `this.cache$.value` directly avoids the issue.
this.sub = this.source$.subscribe(updates => {
this.processUpdates(updates, this.cache$.value)
})
}
/**
* Stop listening, complete all watched node subjects, and reset the cache.
* Calling `stop()` when already stopped is a no-op.
*/
stop() {
if (!this.sub) return
@@ -52,21 +89,40 @@ export class PatchDB<T extends { [key: string]: any }> {
this.cache$.next({ id: 0, value: {} as T })
}
/**
* Returns an observable of the value at the given path within the document.
*
* Overloaded for 06 path segments with full type safety. The returned
* observable emits whenever a patch touches the watched path (or any
* ancestor/descendant of it).
*
* The observable waits for the first non-zero revision (i.e. a real dump)
* before emitting, so subscribers won't see the empty initial state.
*
* @example
* ```ts
* // Watch the entire document
* db.watch$().subscribe(state => ...)
*
* // Watch a nested path
* db.watch$('users', 'abc123', 'name').subscribe(name => ...)
* ```
*/
// @claude fix #13: Removed outer NonNullable wrapper from the 1-level
// overload return type. Runtime values can be null/undefined (e.g. after a
// remove operation), so the previous NonNullable<T[P1]> was unsound — callers
// skipped null checks based on the type, leading to runtime crashes.
watch$(): Observable<T>
watch$<P1 extends keyof T>(p1: P1): Observable<NonNullable<T[P1]>>
watch$<P1 extends keyof T>(p1: P1): Observable<T[P1]>
watch$<P1 extends keyof T, P2 extends keyof NonNullable<T[P1]>>(
p1: P1,
p2: P2,
): Observable<NonNullable<NonNullable<T[P1]>[P2]>>
): Observable<NonNullable<T[P1]>[P2]>
watch$<
P1 extends keyof T,
P2 extends keyof NonNullable<T[P1]>,
P3 extends keyof NonNullable<NonNullable<T[P1]>[P2]>,
>(
p1: P1,
p2: P2,
p3: P3,
): Observable<NonNullable<NonNullable<NonNullable<T[P1]>[P2]>[P3]>>
>(p1: P1, p2: P2, p3: P3): Observable<NonNullable<NonNullable<T[P1]>[P2]>[P3]>
watch$<
P1 extends keyof T,
P2 extends keyof NonNullable<T[P1]>,
@@ -77,9 +133,7 @@ export class PatchDB<T extends { [key: string]: any }> {
p2: P2,
p3: P3,
p4: P4,
): Observable<
NonNullable<NonNullable<NonNullable<NonNullable<T[P1]>[P2]>[P3]>[P4]>
>
): Observable<NonNullable<NonNullable<NonNullable<T[P1]>[P2]>[P3]>[P4]>
watch$<
P1 extends keyof T,
P2 extends keyof NonNullable<T[P1]>,
@@ -95,9 +149,7 @@ export class PatchDB<T extends { [key: string]: any }> {
p4: P4,
p5: P5,
): Observable<
NonNullable<
NonNullable<NonNullable<NonNullable<NonNullable<T[P1]>[P2]>[P3]>[P4]>[P5]
>
NonNullable<NonNullable<NonNullable<NonNullable<T[P1]>[P2]>[P3]>[P4]>[P5]
>
watch$<
P1 extends keyof T,
@@ -119,12 +171,8 @@ export class PatchDB<T extends { [key: string]: any }> {
p6: P6,
): Observable<
NonNullable<
NonNullable<
NonNullable<
NonNullable<NonNullable<NonNullable<T[P1]>[P2]>[P3]>[P4]
>[P5]
>[P6]
>
NonNullable<NonNullable<NonNullable<NonNullable<T[P1]>[P2]>[P3]>[P4]>[P5]
>[P6]
>
watch$(...args: (string | number)[]): Observable<any> {
return this.cache$.pipe(
@@ -143,11 +191,33 @@ export class PatchDB<T extends { [key: string]: any }> {
)
}
proccessUpdates(updates: Update<T>[], cache: Dump<T>) {
/**
* Processes a batch of updates (dumps and/or revisions) against the cache.
*
* Revisions with an id below the expected next revision are skipped (deduplication).
* Revisions that skip ahead (gap detected) are applied with a warning, since the
* state may be inconsistent until the next full dump.
*
* After all updates are applied, the cache subject emits the new state.
*
* @param updates - The batch of updates to process.
* @param cache - The current cache (mutated in place).
*/
processUpdates(updates: Update<T>[], cache: Dump<T>) {
updates.forEach(update => {
if (this.isRevision(update)) {
const expected = cache.id + 1
if (update.id < expected) return
// @claude fix #7: Previously, revision gaps were silently applied. If
// revision 4 was missing and 5 arrived (cache at 3), the patch was
// applied without revision 4's changes, producing corrupt state with
// no indication. Now logs a warning so the issue is visible.
if (update.id > expected) {
console.warn(
`[patch-db] Revision gap detected: expected ${expected}, got ${update.id}. ` +
`State may be inconsistent until the next full dump.`,
)
}
this.handleRevision(update, cache)
} else {
this.handleDump(update, cache)
@@ -157,17 +227,28 @@ export class PatchDB<T extends { [key: string]: any }> {
this.cache$.next(cache)
}
/** @deprecated Use {@link processUpdates} instead. */
proccessUpdates(updates: Update<T>[], cache: Dump<T>) {
this.processUpdates(updates, cache)
}
private handleRevision(revision: Revision, cache: Dump<T>): void {
// apply opperations
// apply operations
revision.patch.forEach(op => {
applyOperation(cache, op)
})
// @claude fix #20: Previously, arrayFromPath(op.path) was called for every
// (watchedNode, patchOp) pair — O(watchedNodes × patchOps) redundant parsing.
// Pre-converting once outside the loop makes it O(patchOps + watchedNodes).
const patchArrs = revision.patch.map(op => ({
path: op.path,
arr: arrayFromPath(op.path),
}))
// update watched nodes
Object.entries(this.watchedNodes).forEach(([watchedPath, { pathArr }]) => {
const match = revision.patch.find(({ path }) => {
const arr = arrayFromPath(path)
return startsWith(pathArr, arr) || startsWith(arr, pathArr)
})
const match = patchArrs.find(
({ arr }) => startsWith(pathArr, arr) || startsWith(arr, pathArr),
)
if (match) this.updateWatchedNode(watchedPath, cache.value)
})
}