diff --git a/CHANGELOG.md b/CHANGELOG.md index 34c1d05..b019356 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- added: `syncedDocument` accepts an optional `SyncedDocumentOptions` object with `cleanFailStrategy` (`'preserve'` to keep the last good value, `'reset'` to revert to defaults) and `onCleanFail` callback for notification when the cleaner rejects a document. + ## 0.2.23 (2025-09-11) - added: Accept a chunk size for rolling database streaming queries. diff --git a/src/couchdb/synced-document.ts b/src/couchdb/synced-document.ts index ba6936c..3a01cce 100644 --- a/src/couchdb/synced-document.ts +++ b/src/couchdb/synced-document.ts @@ -1,4 +1,4 @@ -import { asMaybe, Cleaner, uncleaner } from 'cleaners' +import { Cleaner, uncleaner } from 'cleaners' import { DocumentScope } from 'nano' import { makeEvent, OnEvent } from 'yavent' @@ -37,17 +37,22 @@ export interface SyncedDocument { * The fallback will be the initial value of the returned babysitter, * until `sync` is called to sync with the database. */ +export interface SyncedDocumentOptions { + cleanFailStrategy?: 'preserve' | 'reset' + onCleanFail?: (error: unknown) => void +} + export function syncedDocument( id: string, - cleaner: Cleaner + cleaner: Cleaner, + opts: SyncedDocumentOptions = {} ): SyncedDocument { - const fallback = cleaner({}) - const asDocument = asMaybe(cleaner, fallback) - const wasDocument = uncleaner(asDocument) + const { cleanFailStrategy = 'reset', onCleanFail } = opts + const wasDocument = uncleaner(cleaner) const [on, emit] = makeEvent() const out: SyncedDocument = { - doc: fallback, + doc: cleaner({}), rev: undefined, id, onChange: on, @@ -57,7 +62,17 @@ export function syncedDocument( if (asMaybeNotFoundError(error) == null) throw error return { _id: id, _rev: undefined } }) - const clean = asDocument(rest) + + let clean: T + try { + clean = cleaner(rest) + } catch (error) { + try { + onCleanFail?.(error) + } catch {} + clean = cleanFailStrategy === 'preserve' ? out.doc : cleaner({}) + } + const dirty = wasDocument(clean) if (_rev == null || !matchJson(dirty, rest)) { const result = await db.insert({ _id, _rev, ...dirty }) diff --git a/test/couchdb/synced-document.test.ts b/test/couchdb/synced-document.test.ts new file mode 100644 index 0000000..372fc94 --- /dev/null +++ b/test/couchdb/synced-document.test.ts @@ -0,0 +1,202 @@ +import { expect } from 'chai' +import { asNumber, asObject, asOptional, asString } from 'cleaners' +import { describe, it } from 'mocha' +import { DocumentScope } from 'nano' + +import { syncedDocument } from '../../src/couchdb/synced-document' + +const asConfig = asObject({ + name: asOptional(asString, 'default'), + count: asOptional(asNumber, 0) +}) + +type Config = ReturnType + +function makeMockDb( + store: Record = {} +): DocumentScope { + let revCounter = 0 + return { + get: async (id: string) => { + const doc = store[id] + if (doc == null) + throw Object.assign(new Error('not_found'), { error: 'not_found' }) + return { _id: id, ...doc } + }, + insert: async (doc: { + _id: string + _rev?: string + [key: string]: unknown + }) => { + const rev = `${++revCounter}-abc` + const { _id, ...rest } = doc + store[_id] = { ...rest, _rev: rev } + return { ok: true, id: _id, rev } + } + } as any +} + +describe('syncedDocument', function () { + it('initializes with fallback from cleaner({})', function () { + const doc = syncedDocument('config', asConfig) + expect(doc.doc).deep.equals({ name: 'default', count: 0 }) + expect(doc.rev).equals(undefined) + expect(doc.id).equals('config') + }) + + it('creates missing documents on sync', async function () { + const doc = syncedDocument('config', asConfig) + const db = makeMockDb() + + await doc.sync(db) + + expect(doc.doc).deep.equals({ name: 'default', count: 0 }) + expect(doc.rev).to.be.a('string') + }) + + it('reads existing clean documents', async function () { + const store = { + config: { _rev: '1-existing', name: 'prod', count: 42 } + } + const doc = syncedDocument('config', asConfig) + const db = makeMockDb(store) + + await doc.sync(db) + + expect(doc.doc).deep.equals({ name: 'prod', count: 42 }) + expect(doc.rev).equals('1-existing') + }) + + it('repairs dirty documents (extra fields)', async function () { + const store = { + config: { _rev: '1-existing', name: 'prod', count: 42, extra: 'junk' } + } + const doc = syncedDocument('config', asConfig) + const db = makeMockDb(store) + + await doc.sync(db) + + expect(doc.doc).deep.equals({ name: 'prod', count: 42 }) + expect(doc.rev).not.equals('1-existing') + }) + + it('fires onChange when document is created', async function () { + const doc = syncedDocument('config', asConfig) + const db = makeMockDb() + const changes: Config[] = [] + doc.onChange(value => changes.push(value)) + + await doc.sync(db) + + expect(changes).to.have.length(1) + expect(changes[0]).deep.equals({ name: 'default', count: 0 }) + }) + + it('does not fire onChange when nothing changed', async function () { + const store = { + config: { _rev: '1-existing', name: 'default', count: 0 } + } + const doc = syncedDocument('config', asConfig) + const db = makeMockDb(store) + await doc.sync(db) + + const changes: Config[] = [] + doc.onChange(value => changes.push(value)) + await doc.sync(db) + + expect(changes).to.have.length(0) + }) + + describe('cleanFailStrategy = "reset" (default)', function () { + it('resets to fallback when document fails cleaner', async function () { + const store = { + config: { _rev: '1-existing', name: 'prod', count: 42 } + } + const doc = syncedDocument('config', asConfig) + const db = makeMockDb(store) + + await doc.sync(db) + expect(doc.doc).deep.equals({ name: 'prod', count: 42 }) + + // Corrupt the document: + store.config = { + _rev: store.config._rev, + name: 999 as any, + count: 'bad' as any + } + + await doc.sync(db) + expect(doc.doc).deep.equals({ name: 'default', count: 0 }) + }) + }) + + describe('cleanFailStrategy = "preserve"', function () { + it('preserves last good value when document fails cleaner', async function () { + const store = { + config: { _rev: '1-existing', name: 'prod', count: 42 } + } + const doc = syncedDocument('config', asConfig, { + cleanFailStrategy: 'preserve' + }) + const db = makeMockDb(store) + + await doc.sync(db) + expect(doc.doc).deep.equals({ name: 'prod', count: 42 }) + + store.config = { + _rev: store.config._rev, + name: 999 as any, + count: 'bad' as any + } + + await doc.sync(db) + expect(doc.doc).deep.equals({ name: 'prod', count: 42 }) + }) + + it('falls back to cleaner({}) when no previous good state exists', async function () { + const store = { + config: { _rev: '1-existing', name: 999 as any, count: 'bad' as any } + } + const doc = syncedDocument('config', asConfig, { + cleanFailStrategy: 'preserve' + }) + const db = makeMockDb(store) + + await doc.sync(db) + expect(doc.doc).deep.equals({ name: 'default', count: 0 }) + }) + }) + + describe('onCleanFail callback', function () { + it('fires with the error when the cleaner fails', async function () { + const store = { + config: { _rev: '1-existing', name: 999 as any, count: 'bad' as any } + } + const errors: unknown[] = [] + const doc = syncedDocument('config', asConfig, { + onCleanFail: error => errors.push(error) + }) + const db = makeMockDb(store) + + await doc.sync(db) + + expect(errors).to.have.length(1) + expect(errors[0]).to.be.an('error') + }) + + it('does not fire when the cleaner succeeds', async function () { + const store = { + config: { _rev: '1-existing', name: 'prod', count: 42 } + } + const errors: unknown[] = [] + const doc = syncedDocument('config', asConfig, { + onCleanFail: error => errors.push(error) + }) + const db = makeMockDb(store) + + await doc.sync(db) + + expect(errors).to.have.length(0) + }) + }) +})