mirror of
https://github.com/tompro/sattle.git
synced 2026-08-27 07:15:59 +00:00
feat: commit bearer changesets in one write
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
import {describe, expect, it, vi} from 'vitest'
|
||||
import {buildNoteUrl} from 'lnurlcash-kit'
|
||||
import type {Bearer} from '../types'
|
||||
import {deriveBearerAesKey} from '../keys'
|
||||
import {deleteBearerRecord, newBearerId, persistBearer} from '../storage'
|
||||
import {stubLocalStorage} from '../test-utils'
|
||||
|
||||
const LINKING_KEY = new Uint8Array(32).fill(7)
|
||||
const K1 = 'aa'.repeat(32)
|
||||
const bearerFixture = (): Bearer => ({
|
||||
id: newBearerId(),
|
||||
url: buildNoteUrl('https://mint.example/w', K1, 21_000),
|
||||
callback: 'https://mint.example/w/cb',
|
||||
amount: 21_000,
|
||||
verified: true,
|
||||
createdAt: 1000,
|
||||
updatedAt: 1000,
|
||||
})
|
||||
describe('baseline: per-record bearer persistence', () => {
|
||||
it('persistBearer/deleteBearerRecord perform one write per call', async () => {
|
||||
const storage = stubLocalStorage()
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
const writes = vi.spyOn(storage, 'setItem')
|
||||
await persistBearer(key, {...bearerFixture(), id: 'a'})
|
||||
await persistBearer(key, {...bearerFixture(), id: 'b'})
|
||||
await deleteBearerRecord('a')
|
||||
expect(writes.mock.calls.filter(([keyName]) => keyName === 'sattle_bearers')).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,305 @@
|
||||
// Bearer changeset persistence: applyBearerChangeset (the single-write
|
||||
// commit primitive) plus a baseline pin of the per-record write behavior it
|
||||
// replaces at the call sites. Lives next to bearers.ts instead of inside
|
||||
// ../storage.test.ts to keep both files under the project's module size
|
||||
// ceiling. Runs in Node against an in-memory localStorage stub; WebCrypto
|
||||
// (crypto.subtle) is native.
|
||||
|
||||
import {beforeEach, describe, expect, it, vi} from 'vitest'
|
||||
import {buildNoteUrl} from 'lnurlcash-kit'
|
||||
|
||||
import type {Bearer, NewBearer} from '../types'
|
||||
import {deriveBearerAesKey, encryptRecord} from '../keys'
|
||||
import {
|
||||
applyBearerChangeset,
|
||||
loadBearers,
|
||||
newBearerId,
|
||||
persistBearer,
|
||||
readEncryptedBearers,
|
||||
} from '../storage'
|
||||
import type {BearerChangeset} from '../storage'
|
||||
import {writeEncryptedBearers} from './bearers'
|
||||
import {requiredValue, stubLocalStorage} from '../test-utils'
|
||||
import type {LocalStorageStub} from '../test-utils'
|
||||
import './bearers.baseline.cases'
|
||||
|
||||
const LINKING_KEY = new Uint8Array(32).fill(7)
|
||||
const OTHER_KEY = new Uint8Array(32).fill(9)
|
||||
|
||||
const K1_A = 'aa'.repeat(32)
|
||||
const K1_B = 'bb'.repeat(32)
|
||||
const K1_C = 'cc'.repeat(32)
|
||||
const K1_D = 'dd'.repeat(32)
|
||||
|
||||
const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
|
||||
id: newBearerId(),
|
||||
url: buildNoteUrl('https://mint.example/w', K1_A, 21_000),
|
||||
callback: 'https://mint.example/w/cb',
|
||||
amount: 21_000,
|
||||
verified: true,
|
||||
createdAt: 1000,
|
||||
updatedAt: 1000,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const newBearerFixture = (overrides: Partial<NewBearer> = {}): NewBearer => ({
|
||||
url: buildNoteUrl('https://mint.example/w', K1_C, 3_000),
|
||||
callback: 'https://mint.example/w/cb',
|
||||
amount: 3_000,
|
||||
verified: true,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
let stub: LocalStorageStub
|
||||
|
||||
beforeEach(() => {
|
||||
stub = stubLocalStorage()
|
||||
})
|
||||
|
||||
const bearerWrites = (spy: {mock: {calls: unknown[][]}}): unknown[][] =>
|
||||
spy.mock.calls.filter(([k]) => k === 'sattle_bearers')
|
||||
|
||||
describe('applyBearerChangeset (single-write changeset commit)', () => {
|
||||
it('commits additions and spent replacements with exactly one write', async () => {
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
const oldA = bearerFixture({id: 'old-a'})
|
||||
const oldB = bearerFixture({
|
||||
id: 'old-b',
|
||||
url: buildNoteUrl('https://mint.example/w', K1_B, 5_000),
|
||||
amount: 5_000,
|
||||
})
|
||||
await persistBearer(key, oldA)
|
||||
await persistBearer(key, oldB)
|
||||
|
||||
const writes = vi.spyOn(stub, 'setItem')
|
||||
const result = await applyBearerChangeset(key, [oldA, oldB], {
|
||||
add: [
|
||||
newBearerFixture(),
|
||||
newBearerFixture({
|
||||
url: buildNoteUrl('https://mint.example/w', K1_D, 4_000),
|
||||
amount: 4_000,
|
||||
}),
|
||||
],
|
||||
markSpent: ['old-a', 'old-b'],
|
||||
})
|
||||
|
||||
// the whole changeset is ONE setItem on sattle_bearers
|
||||
expect(bearerWrites(writes)).toHaveLength(1)
|
||||
|
||||
// the returned next list: additions first, then the snapshot with spent
|
||||
// marks applied
|
||||
expect(result).toHaveLength(4)
|
||||
const addA = requiredValue(result[0])
|
||||
const addB = requiredValue(result[1])
|
||||
const spentA = requiredValue(result[2])
|
||||
const spentB = requiredValue(result[3])
|
||||
expect(addA.id).not.toBe(addB.id)
|
||||
expect(addA.amount).toBe(3_000)
|
||||
expect(addB.amount).toBe(4_000)
|
||||
expect(addA.createdAt).toBe(addA.updatedAt)
|
||||
expect(spentA.id).toBe('old-a')
|
||||
expect(spentA.spent).toBe(true)
|
||||
expect(spentA.updatedAt).toBeGreaterThan(1000)
|
||||
expect(spentB.id).toBe('old-b')
|
||||
expect(spentB.spent).toBe(true)
|
||||
|
||||
// the source of truth is the reloaded ciphertext, not the return value
|
||||
const reloaded = await loadBearers(key)
|
||||
expect(reloaded.map((b) => b.id).sort()).toEqual(result.map((b) => b.id).sort())
|
||||
expect(requiredValue(reloaded.find((b) => b.id === 'old-a')).spent).toBe(true)
|
||||
expect(requiredValue(reloaded.find((b) => b.id === 'old-b')).spent).toBe(true)
|
||||
expect(requiredValue(reloaded.find((b) => b.id === addA.id)).spent).toBeUndefined()
|
||||
// nothing plaintext leaked: the fresh k1s are ciphertext-only at rest
|
||||
const raw = requiredValue(localStorage.getItem('sattle_bearers'))
|
||||
expect(raw).not.toContain(K1_C)
|
||||
expect(raw).not.toContain(K1_D)
|
||||
})
|
||||
|
||||
it('never mutates the caller snapshot or the changeset', async () => {
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
const snapshot = [bearerFixture({id: 's1'})]
|
||||
const changeset: BearerChangeset = {
|
||||
add: [newBearerFixture()],
|
||||
markSpent: ['s1'],
|
||||
}
|
||||
|
||||
await applyBearerChangeset(key, snapshot, changeset)
|
||||
|
||||
expect(requiredValue(snapshot[0]).spent).toBeUndefined()
|
||||
expect('id' in requiredValue(changeset.add[0])).toBe(false)
|
||||
expect(changeset.markSpent).toEqual(['s1'])
|
||||
})
|
||||
|
||||
it('persists nothing when encryption fails', async () => {
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
await persistBearer(key, bearerFixture({id: 'kept'}))
|
||||
// a decrypt-only key makes every AES-GCM encrypt call reject
|
||||
const decryptOnly = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
new Uint8Array(32).fill(3),
|
||||
'AES-GCM',
|
||||
false,
|
||||
['decrypt'],
|
||||
)
|
||||
const writes = vi.spyOn(stub, 'setItem')
|
||||
|
||||
await expect(
|
||||
applyBearerChangeset(decryptOnly, [], {
|
||||
add: [newBearerFixture()],
|
||||
markSpent: [],
|
||||
}),
|
||||
).rejects.toThrow()
|
||||
|
||||
expect(bearerWrites(writes)).toHaveLength(0)
|
||||
expect(readEncryptedBearers().map((r) => r.id)).toEqual(['kept'])
|
||||
})
|
||||
|
||||
it('rejects without a partial write when the storage write itself fails', async () => {
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
const kept = bearerFixture({id: 'kept'})
|
||||
await persistBearer(key, kept)
|
||||
const before = readEncryptedBearers()
|
||||
stub.setItem = (): void => {
|
||||
throw new Error('QuotaExceededError')
|
||||
}
|
||||
// run this one through the unlocked fallback on purpose: a quota throw
|
||||
// is SYNCHRONOUS, and Node 24's real navigator.locks never releases a
|
||||
// lock whose callback throws synchronously (verified Node quirk - every
|
||||
// browser releases per the Web Locks spec), which would wedge
|
||||
// 'sattle_bearers' for the rest of the file. Bonus: one test keeps the
|
||||
// documented plain-Node fallback path (storageLock.ts) exercised.
|
||||
vi.stubGlobal('navigator', {})
|
||||
try {
|
||||
await expect(
|
||||
applyBearerChangeset(key, [kept], {
|
||||
add: [newBearerFixture()],
|
||||
markSpent: ['kept'],
|
||||
}),
|
||||
).rejects.toThrow('QuotaExceededError')
|
||||
|
||||
// nothing was persisted: the pre-existing record is byte-identical
|
||||
expect(readEncryptedBearers()).toEqual(before)
|
||||
} finally {
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
|
||||
it('upserts changed ids and dedupes repeated markSpent ids', async () => {
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
const stale = bearerFixture({id: 'dup', updatedAt: 1000})
|
||||
await persistBearer(key, stale)
|
||||
|
||||
const result = await applyBearerChangeset(key, [stale], {
|
||||
add: [],
|
||||
markSpent: ['dup', 'dup'],
|
||||
})
|
||||
|
||||
// one record per id, never a duplicate append
|
||||
expect(readEncryptedBearers().filter((r) => r.id === 'dup')).toHaveLength(1)
|
||||
const reloaded = await loadBearers(key)
|
||||
expect(reloaded).toHaveLength(1)
|
||||
expect(requiredValue(reloaded[0]).spent).toBe(true)
|
||||
expect(requiredValue(result[0]).spent).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves a record another tab commits between the snapshot and the lock', async () => {
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
const other = await deriveBearerAesKey(OTHER_KEY)
|
||||
const mine = bearerFixture({id: 'mine'})
|
||||
await persistBearer(key, mine)
|
||||
|
||||
// a controllable Web Locks fake: lock requests park until the test
|
||||
// releases them, so a foreign write can interleave deterministically
|
||||
const queue: {name: string; release: () => Promise<void>}[] = []
|
||||
vi.stubGlobal('navigator', {
|
||||
locks: {
|
||||
request: (name: string, fn: () => unknown): Promise<unknown> =>
|
||||
new Promise((resolve, reject) => {
|
||||
queue.push({
|
||||
name,
|
||||
release: async () => {
|
||||
try {
|
||||
resolve(await fn())
|
||||
} catch (error) {
|
||||
reject(error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
},
|
||||
})
|
||||
}),
|
||||
},
|
||||
})
|
||||
try {
|
||||
const commit = applyBearerChangeset(key, [mine], {
|
||||
add: [newBearerFixture()],
|
||||
markSpent: [],
|
||||
})
|
||||
// encryption happens BEFORE the lock request; wait for it to arrive
|
||||
await vi.waitFor(() => {
|
||||
expect(queue).toHaveLength(1)
|
||||
})
|
||||
const queuedLock = requiredValue(queue[0])
|
||||
expect(queuedLock.name).toBe('sattle_bearers')
|
||||
|
||||
// while our commit waits on the lock, another tab commits a record we
|
||||
// cannot even decrypt (written under a different seed's key)
|
||||
const foreign = bearerFixture({
|
||||
id: 'foreign-tab',
|
||||
url: buildNoteUrl('https://mint.example/w', K1_B, 9_000),
|
||||
})
|
||||
const {id: foreignId, ...foreignPlain} = foreign
|
||||
const foreignParts = await encryptRecord(other, foreignPlain)
|
||||
writeEncryptedBearers([...readEncryptedBearers(), {id: foreignId, ...foreignParts}])
|
||||
|
||||
await queuedLock.release()
|
||||
const result = await commit
|
||||
|
||||
// the foreign ciphertext survived our upsert, untouched
|
||||
expect(
|
||||
readEncryptedBearers()
|
||||
.map((r) => r.id)
|
||||
.sort(),
|
||||
).toEqual(['foreign-tab', 'mine', requiredValue(result[0]).id].sort())
|
||||
expect((await loadBearers(other)).map((b) => b.id)).toEqual(['foreign-tab'])
|
||||
expect((await loadBearers(key)).map((b) => b.id).sort()).toEqual(
|
||||
['mine', requiredValue(result[0]).id].sort(),
|
||||
)
|
||||
} finally {
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
|
||||
it('treats corrupted stored JSON as an empty record set instead of throwing', async () => {
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
localStorage.setItem('sattle_bearers', 'not json {{{')
|
||||
|
||||
const result = await applyBearerChangeset(key, [], {
|
||||
add: [newBearerFixture()],
|
||||
markSpent: ['gone'],
|
||||
})
|
||||
|
||||
// readEncryptedBearers' long-standing contract: unparseable storage
|
||||
// reads as [] (malformed entries are dropped, never thrown on) - the
|
||||
// changeset still commits and its single write replaces the corrupt blob
|
||||
expect(result).toHaveLength(1)
|
||||
expect((await loadBearers(key)).map((b) => b.id)).toEqual([requiredValue(result[0]).id])
|
||||
})
|
||||
|
||||
it('ignores markSpent ids absent from the snapshot and writes nothing when nothing changed', async () => {
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
const other = await deriveBearerAesKey(OTHER_KEY)
|
||||
await persistBearer(other, bearerFixture({id: 'foreign'}))
|
||||
const writes = vi.spyOn(stub, 'setItem')
|
||||
|
||||
// 'foreign' is not in the caller's snapshot; deriving its spent copy
|
||||
// would require decrypting an unrelated record, which this primitive
|
||||
// never does - so the changeset changes nothing and performs no write
|
||||
const result = await applyBearerChangeset(key, [], {
|
||||
add: [],
|
||||
markSpent: ['foreign'],
|
||||
})
|
||||
|
||||
expect(result).toEqual([])
|
||||
expect(bearerWrites(writes)).toHaveLength(0)
|
||||
expect(readEncryptedBearers().map((r) => r.id)).toEqual(['foreign'])
|
||||
})
|
||||
})
|
||||
@@ -4,7 +4,8 @@
|
||||
|
||||
import type {EncryptedRecordParts} from '../keys'
|
||||
import {encryptRecord, decryptRecord} from '../keys'
|
||||
import type {Bearer} from '../types'
|
||||
import type {Bearer, NewBearer} from '../types'
|
||||
import {isJsonObject} from '../jsonParsing'
|
||||
import {noteK1, serverOf} from 'lnurlcash-kit'
|
||||
import {withStorageLock} from '../storageLock'
|
||||
|
||||
@@ -15,11 +16,31 @@ export const compareBearerOrder = (a: Bearer, b: Bearer): number =>
|
||||
|
||||
export type EncryptedBearerRecord = {id: string} & EncryptedRecordParts
|
||||
|
||||
const isEncryptedBearerRecord = (value: unknown): value is EncryptedBearerRecord =>
|
||||
isJsonObject(value) &&
|
||||
typeof value.id === 'string' &&
|
||||
typeof value.iv === 'string' &&
|
||||
typeof value.ciphertext === 'string'
|
||||
|
||||
const isStoredBearer = (value: unknown): value is Omit<Bearer, 'id'> =>
|
||||
isJsonObject(value) &&
|
||||
typeof value.url === 'string' &&
|
||||
typeof value.callback === 'string' &&
|
||||
typeof value.amount === 'number' &&
|
||||
typeof value.verified === 'boolean' &&
|
||||
typeof value.createdAt === 'number' &&
|
||||
typeof value.updatedAt === 'number' &&
|
||||
(value.mintPubkey === undefined || typeof value.mintPubkey === 'string') &&
|
||||
(value.spent === undefined || typeof value.spent === 'boolean') &&
|
||||
(value.sortIndex === undefined || typeof value.sortIndex === 'number') &&
|
||||
(value.label === undefined || typeof value.label === 'string') &&
|
||||
(value.deviceId === undefined || typeof value.deviceId === 'string')
|
||||
|
||||
const BEARERS_STORAGE_KEY = 'sattle_bearers'
|
||||
|
||||
export const newBearerId = (): string =>
|
||||
Array.from(crypto.getRandomValues(new Uint8Array(8)))
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
|
||||
export const readEncryptedBearers = (): EncryptedBearerRecord[] => {
|
||||
@@ -27,18 +48,18 @@ export const readEncryptedBearers = (): EncryptedBearerRecord[] => {
|
||||
if (!raw) return []
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
return Array.isArray(parsed) ? parsed : []
|
||||
return Array.isArray(parsed) ? parsed.filter(isEncryptedBearerRecord) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export const writeEncryptedBearers = (
|
||||
records: EncryptedBearerRecord[]
|
||||
): void => {
|
||||
export const writeEncryptedBearers = (records: EncryptedBearerRecord[]): void => {
|
||||
localStorage.setItem(BEARERS_STORAGE_KEY, JSON.stringify(records))
|
||||
}
|
||||
|
||||
type BearerCommitOptions = {beforeCommit?: () => void}
|
||||
|
||||
// decrypts everything currently stored - a record that fails to decrypt
|
||||
// (e.g. written by a different seed's key) is skipped, not destroyed: it
|
||||
// stays in localStorage untouched and simply doesn't show up
|
||||
@@ -46,10 +67,12 @@ export const loadBearers = async (aesKey: CryptoKey): Promise<Bearer[]> => {
|
||||
const bearers: Bearer[] = []
|
||||
for (const record of readEncryptedBearers()) {
|
||||
try {
|
||||
const bearer = await decryptRecord<Omit<Bearer, 'id'>>(aesKey, record)
|
||||
const bearer = await decryptRecord(aesKey, record)
|
||||
if (!isStoredBearer(bearer)) throw new Error('Malformed encrypted bearer record.')
|
||||
bearers.push({...bearer, id: record.id})
|
||||
} catch {
|
||||
} catch (error) {
|
||||
// undecryptable with this key - leave it in place
|
||||
if (!(error instanceof Error)) throw error
|
||||
}
|
||||
}
|
||||
return bearers.sort((a, b) => b.createdAt - a.createdAt)
|
||||
@@ -57,23 +80,123 @@ export const loadBearers = async (aesKey: CryptoKey): Promise<Bearer[]> => {
|
||||
|
||||
export const persistBearer = async (
|
||||
aesKey: CryptoKey,
|
||||
bearer: Bearer
|
||||
bearer: Bearer,
|
||||
options: BearerCommitOptions = {},
|
||||
): Promise<void> => {
|
||||
const {id, ...plain} = bearer
|
||||
const parts = await encryptRecord(aesKey, plain)
|
||||
await withStorageLock(BEARERS_STORAGE_KEY, () => {
|
||||
const records = readEncryptedBearers().filter(r => r.id !== id)
|
||||
options.beforeCommit?.()
|
||||
const records = readEncryptedBearers().filter((r) => r.id !== id)
|
||||
records.push({id, ...parts})
|
||||
writeEncryptedBearers(records)
|
||||
})
|
||||
}
|
||||
|
||||
export const deleteBearerRecord = async (id: string): Promise<void> => {
|
||||
export const deleteBearerRecord = async (
|
||||
id: string,
|
||||
options: BearerCommitOptions = {},
|
||||
): Promise<void> => {
|
||||
await withStorageLock(BEARERS_STORAGE_KEY, () => {
|
||||
writeEncryptedBearers(readEncryptedBearers().filter(r => r.id !== id))
|
||||
options.beforeCommit?.()
|
||||
writeEncryptedBearers(readEncryptedBearers().filter((r) => r.id !== id))
|
||||
})
|
||||
}
|
||||
|
||||
// The atomic unit of bearer persistence: fresh notes to start tracking plus
|
||||
// ids of snapshot notes to lock as spent. Born-spent notes (carved and
|
||||
// melted away in one flow) are deliberately not representable - they were
|
||||
// never the wallet's money in a trackable state.
|
||||
export type BearerChangeset = {
|
||||
add: NewBearer[]
|
||||
markSpent: string[]
|
||||
upsert?: Bearer[]
|
||||
remove?: string[]
|
||||
}
|
||||
|
||||
// Commits a whole changeset as ONE storage write - the fund-critical
|
||||
// boundary a caller (NWC service, wallet store) awaits before reporting
|
||||
// success. The per-record path above (persistBearer in a loop) can die
|
||||
// halfway through a melt: some records persisted, some not, while the
|
||||
// caller's reactive state already moved on. Here nothing becomes
|
||||
// observable until the single locked write lands:
|
||||
//
|
||||
// - every changed Bearer value is derived from the caller's snapshot: added
|
||||
// notes get their id/timestamps assigned HERE (so state and storage can
|
||||
// never disagree about them), spent marks copy the snapshot's record
|
||||
// - ALL encryption happens BEFORE the lock is taken - crypto is the slow,
|
||||
// async part and a storage lock must never be held across it (see
|
||||
// storageLock.ts); if any record fails to encrypt, no write happens at
|
||||
// all
|
||||
// - inside the lock the encrypted records are re-read FRESH, so records
|
||||
// another tab committed after the caller's snapshot survive the upsert -
|
||||
// changed ids replace their stored copy, everything else is kept as-is
|
||||
// (unrelated records are never decrypted or re-encrypted)
|
||||
// - markSpent ids absent from the snapshot are ignored: deriving them would
|
||||
// require decrypting a record the caller doesn't hold
|
||||
// - a changeset that changes nothing performs no write at all
|
||||
// - caller arrays are never mutated
|
||||
// - options.beforeCommit runs synchronously INSIDE the lock, immediately
|
||||
// before the single write: the caller's last-chance fence (the wallet
|
||||
// store revalidates persisted ownership there). Throwing aborts the
|
||||
// commit with storage untouched. Four parameters are deliberate here:
|
||||
// the fence is an orthogonal hook, not changeset data, and grouping it
|
||||
// into the changeset would let callers persist it by accident.
|
||||
//
|
||||
// Returns the next local bearer list (additions first, then the snapshot
|
||||
// with spent marks applied) only after the write succeeded; on any failure
|
||||
// the promise rejects and persisted state is untouched.
|
||||
export const applyBearerChangeset = async (
|
||||
aesKey: CryptoKey,
|
||||
snapshot: Bearer[],
|
||||
changeset: BearerChangeset,
|
||||
options: BearerCommitOptions = {},
|
||||
): Promise<Bearer[]> => {
|
||||
const now = Date.now()
|
||||
const added: Bearer[] = changeset.add.map((note) => ({
|
||||
id: newBearerId(),
|
||||
...note,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}))
|
||||
const spentIds = new Set(changeset.markSpent)
|
||||
const spent = new Map<string, Bearer>()
|
||||
for (const bearer of snapshot) {
|
||||
if (spentIds.has(bearer.id)) {
|
||||
spent.set(bearer.id, {...bearer, spent: true, updatedAt: now})
|
||||
}
|
||||
}
|
||||
const upserted = changeset.upsert ?? []
|
||||
const removedIds = new Set(changeset.remove ?? [])
|
||||
const changedById = new Map<string, Bearer>()
|
||||
for (const bearer of spent.values()) changedById.set(bearer.id, bearer)
|
||||
for (const bearer of upserted) changedById.set(bearer.id, bearer)
|
||||
for (const bearer of added) changedById.set(bearer.id, bearer)
|
||||
const changed = [...changedById.values()]
|
||||
if (changed.length === 0 && removedIds.size === 0) return snapshot
|
||||
const encrypted: EncryptedBearerRecord[] = []
|
||||
for (const bearer of changed) {
|
||||
const {id, ...plain} = bearer
|
||||
const parts = await encryptRecord(aesKey, plain)
|
||||
encrypted.push({id, ...parts})
|
||||
}
|
||||
await withStorageLock(BEARERS_STORAGE_KEY, () => {
|
||||
options.beforeCommit?.()
|
||||
const changedIds = new Set(encrypted.map((r) => r.id))
|
||||
const records = readEncryptedBearers().filter(
|
||||
(record) => !changedIds.has(record.id) && !removedIds.has(record.id),
|
||||
)
|
||||
records.push(...encrypted)
|
||||
writeEncryptedBearers(records)
|
||||
})
|
||||
const snapshotIds = new Set(snapshot.map((bearer) => bearer.id))
|
||||
const inserted = upserted.filter((bearer) => !snapshotIds.has(bearer.id))
|
||||
const retained = snapshot
|
||||
.filter((bearer) => !removedIds.has(bearer.id))
|
||||
.map((bearer) => changedById.get(bearer.id) ?? bearer)
|
||||
return [...added, ...inserted, ...retained]
|
||||
}
|
||||
|
||||
// wipes every bearer record from this device outright - unlike forgetting
|
||||
// just the linking key, this is not recoverable by restoring the same seed:
|
||||
// the ciphertexts themselves are gone, so only a previously downloaded
|
||||
@@ -91,10 +214,7 @@ export const clearAllBearers = (): void => {
|
||||
// same spent state, the newer updatedAt wins. This is the merge a restore
|
||||
// (backup file now, nostr later) applies after its records decrypt, and it
|
||||
// is what makes multi-device restores converge instead of duplicate.
|
||||
export const mergeBearers = (
|
||||
current: Bearer[],
|
||||
incoming: Bearer[]
|
||||
): Bearer[] => {
|
||||
export const mergeBearers = (current: Bearer[], incoming: Bearer[]): Bearer[] => {
|
||||
const keyOf = (b: Bearer): string => {
|
||||
const k1 = noteK1(b.url)
|
||||
return k1 ? `${serverOf(b.url)}#${k1}` : `id#${b.id}`
|
||||
|
||||
Reference in New Issue
Block a user