Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 5 additions & 5 deletions docs/reference/package-api-migrations.md

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions governance/package-release-notes.json
Original file line number Diff line number Diff line change
Expand Up @@ -210,21 +210,21 @@
"name": "@bsv/wallet-toolbox",
"publishedVersion": "2.14.4",
"releaseType": "patch",
"summary": "WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes.",
"migration": "No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records."
"summary": "WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. Public Argon2id declarations reference the typed hash-wasm package while retaining the same generic binary/string contract and emitted runtime bytes. Knex storage adds a unique index on sync_states (userId, storageIdentityKey). Concurrent findOrInsertSyncStateAuth calls could insert duplicate rows, after which every sync with that source failed with 'Storage identity has conflicting sync states'; the losing insert now re-reads the winning row. BRC-38 import keeps one sync state per storage identity and merges into the target's row for that identity even when its storage name differs.",
"migration": "No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records. The 2026-09-30-001 Knex migration deletes duplicate sync_states rows, keeping the oldest for each user and storage identity; when the duplicates had different storage names, the kept row restarts as a new sync state and re-syncs from the beginning."
},
{
"name": "@bsv/wallet-toolbox-client",
"publishedVersion": "2.14.4",
"releaseType": "patch",
"summary": "WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions.",
"summary": "WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. BRC-38 import keeps one sync state per storage identity: an export holding several for one identity imports the oldest, and merge updates the target's row for that identity even when its storage name differs.",
"migration": "No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records."
},
{
"name": "@bsv/wallet-toolbox-mobile",
"publishedVersion": "2.14.4",
"releaseType": "patch",
"summary": "WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor.",
"summary": "WalletPermissionsManager retires signed no-send ownership and reference aliases after createAction or signAction reports sending or unproven, including sendWith-only calls and successful members of an undelayed review error. Failed or unreported actions retain their originator binding for retry or abort. Cleanup visits only the retired transaction's references and preserves aliases reused by newer actions. Adds an SDK3 peer alternative while retaining the existing SDK2.8.0 floor. BRC-38 import keeps one sync state per storage identity: an export holding several for one identity imports the oldest, and merge updates the target's row for that identity even when its storage name differs.",
"migration": "No public API, wire, database, or consumer migration is required. Pending no-send actions and retryable failures retain the same originator-bound abort behavior; positively queued or broadcast actions no longer retain session ownership records."
},
{
Expand Down
11 changes: 11 additions & 0 deletions packages/wallet/wallet-toolbox/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@ attention to changes that materially alter behavior or extend functionality.

## wallet-toolbox 2.14.5

- Knex storage adds a unique index on `sync_states` (`userId`,
`storageIdentityKey`). Concurrent `findOrInsertSyncStateAuth` calls for a new
source could each insert a row, after which every sync with that source failed
with `Storage identity has conflicting sync states`. The losing insert now
re-reads the row that won. The migration deletes existing duplicates, keeping
the oldest row; when the duplicates had different storage names, the kept row
restarts as a new sync state because its checkpoint may belong to a different
source database.
- BRC-38 import keeps one sync state per storage identity. An export holding
several for one identity imports the oldest under the same rule, and merge
updates the target's row for that identity even when its storage name differs.
- `WalletPermissionsManager` retires no-send transaction ownership and reference
aliases when `createAction` or `signAction` reports them as `sending` or
`unproven`, including sendWith-only calls and successful members of an
Expand Down
4 changes: 4 additions & 0 deletions packages/wallet/wallet-toolbox/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,10 @@ serialized RPC response exceeds the service ceiling, remote clients retry the
read-only request with a smaller chunk budget and remember the working limit
for the rest of the session.

Knex storage keeps one sync state per user and source storage identity, enforced
by a unique index. Concurrent registrations of the same source return the row the
first caller inserted.

Output synchronization requires a local mapping for every non-null source basket
ID. A missing mapping rejects the page so its transaction and checkpoint can roll
back; retry after transferring the missing basket. Newer source updates apply
Expand Down
52 changes: 36 additions & 16 deletions packages/wallet/wallet-toolbox/src/storage/portable/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -422,10 +422,36 @@
certificateFields: data.tables.certificateFields.map(r =>
fromPortableRow<TableCertificateField>('certificateField', r)
),
syncStates: data.tables.syncStates.map(r => fromPortableRow<TableSyncState>('syncState', r))
syncStates: collapseSyncStates(data.tables.syncStates.map(r => fromPortableRow<TableSyncState>('syncState', r)))
}
}

/**
* Knex storage holds one sync state per user and source storage identity, but
* older exports can contain several. Keep the oldest. Rows with different
* names may describe different source databases that reused one identity key,
* so the kept checkpoint is restarted as a new sync state.
*/
function collapseSyncStates(rows: TableSyncState[]): TableSyncState[] {
const groups = new Map<string, TableSyncState[]>()
for (const row of rows) {
const group = groups.get(row.storageIdentityKey)
if (group == null) groups.set(row.storageIdentityKey, [row])
else group.push(row)
}
return Array.from(groups.values(), group => {
const kept = group.reduce((oldest, row) => (row.syncStateId < oldest.syncStateId ? row : oldest))

Check warning on line 443 in packages/wallet/wallet-toolbox/src/storage/portable/index.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Add an initial value to this "reduce()" call.

See more on https://sonarcloud.io/project/issues?id=bsv-blockchain_ts-stack&issues=AaD2jtOT8yMptmuWwO5S&open=AaD2jtOT8yMptmuWwO5S&pullRequest=723
if (group.every(row => row.storageName === kept.storageName)) return kept
return {
...kept,
status: 'unknown',
init: false,
when: undefined,
syncMap: JSON.stringify(createSyncMap())
}
})
}

async function restoreBRC38(storage: StorageProvider, data: DecodedBRC38): Promise<BRC38ImportResult> {
await assertRestoreTargetEmpty(storage)
await storage.transaction(async trx => {
Expand Down Expand Up @@ -525,7 +551,7 @@
data.syncStates,
targetUser.userId,
importMap,
data.sourceStorage
currentSyncState.syncStateId
)
return {
mode: 'merge',
Expand All @@ -541,7 +567,7 @@
syncStates: TableSyncState[],
userId: number,
importMap: SyncMap,
sourceStorage: TableSettings
importSyncStateId: number
): Promise<{ inserts: number; updates: number }> {
let inserts = 0
let updates = 0
Expand All @@ -551,25 +577,19 @@
userId,
syncMap: JSON.stringify(remapSyncMap(JSON.parse(source.syncMap), importMap))
}
const existing = verifyOneOrNone(
await storage.findSyncStates({
partial: {
userId,
storageIdentityKey: row.storageIdentityKey,
storageName: row.storageName
}
})
)
// Knex storage holds one sync state per storage identity. IndexedDB may
// still hold legacy rows that differ only by name; prefer the exact name.
const matches = await storage.findSyncStates({
partial: { userId, storageIdentityKey: row.storageIdentityKey }
})

Check warning on line 584 in packages/wallet/wallet-toolbox/src/storage/portable/index.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Unexpected `await` inside a loop.

See more on https://sonarcloud.io/project/issues?id=bsv-blockchain_ts-stack&issues=AaD2jtOT8yMptmuWwO5T&open=AaD2jtOT8yMptmuWwO5T&pullRequest=723
const existing = matches.length > 1 ? matches.find(s => s.storageName === row.storageName) : matches[0]
if (existing == null) {
row.syncStateId = 0
await storage.insertSyncState(row)
inserts++
} else {
row.syncStateId = existing.syncStateId
if (
row.storageIdentityKey === sourceStorage.storageIdentityKey &&
row.storageName === sourceStorage.storageName
) {
if (existing.syncStateId === importSyncStateId) {
row.syncMap = existing.syncMap
}
await storage.updateSyncState(existing.syncStateId, row)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import { StorageKnex } from '../StorageKnex'
import { WalletError } from '../../sdk/WalletError'
import { WERR_NOT_IMPLEMENTED } from '../../sdk/WERR_errors'
import { createSyncMap } from './entities/EntityBase'
import {
DEFAULT_MANAGED_CHANGE_MINIMUM_SATOSHIS,
DEFAULT_MANAGED_CHANGE_TARGET_UTXOS,
Expand Down Expand Up @@ -93,6 +94,64 @@
}
}

migrations['2026-09-30-001 unique sync state per storage identity'] = {
async up(knex) {
// Concurrent findOrInsertSyncStateAuth calls could each insert a row,
// after which every lookup for the pair failed. Keep the oldest row.
const groups: Array<{ userId: number; storageIdentityKey: string; keepId: number; names: number | string }> =
await knex('sync_states')
.select('userId', 'storageIdentityKey')
.min({ keepId: 'syncStateId' })
.countDistinct({ names: 'storageName' })
.groupBy('userId', 'storageIdentityKey')
.havingRaw('count(*) > 1')
const restartAt =
groups.length > 0 && (await determineDBType(knex)) === 'SQLite' ? new Date().toISOString() : knex.fn.now(3)
for (const group of groups) {
await knex('sync_states')
.where({ userId: group.userId, storageIdentityKey: group.storageIdentityKey })
.where('syncStateId', '>', group.keepId)
.delete()

Check warning on line 114 in packages/wallet/wallet-toolbox/src/storage/schema/KnexMigrations.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Unexpected `await` inside a loop.

See more on https://sonarcloud.io/project/issues?id=bsv-blockchain_ts-stack&issues=AaD2jtFx8yMptmuWwO5Q&open=AaD2jtFx8yMptmuWwO5Q&pullRequest=723
// Rows with different names may track different source databases
// that reused one identity key, so the kept checkpoint may not
// belong to the next caller. Restart it as a new sync state.
if (Number(group.names) > 1) {
await knex('sync_states')
.where({ syncStateId: group.keepId })
.update({
status: 'unknown',
init: false,
when: null,
syncMap: JSON.stringify(createSyncMap()),
updated_at: restartAt
})

Check warning on line 127 in packages/wallet/wallet-toolbox/src/storage/schema/KnexMigrations.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Unexpected `await` inside a loop.

See more on https://sonarcloud.io/project/issues?id=bsv-blockchain_ts-stack&issues=AaD2jtFx8yMptmuWwO5R&open=AaD2jtFx8yMptmuWwO5R&pullRequest=723
}
}
await knex.schema.alterTable('sync_states', table => {
table.unique(['userId', 'storageIdentityKey'], { indexName: 'sync_states_user_storage_identity' })
})
},
async down(knex) {
// MySQL may discard the automatically-created userId foreign key
// index once the unique index can support the foreign key.
if ((await determineDBType(knex)) === 'MySQL') {
const result = await knex.raw('SHOW INDEX FROM ?? WHERE Key_name = ?', [
'sync_states',
'sync_states_userid_foreign'
])
const indexes = result[0] as unknown[]
if (indexes.length === 0) {
await knex.schema.alterTable('sync_states', table => {
table.index(['userId'], 'sync_states_userid_foreign')
})
}
}
await knex.schema.alterTable('sync_states', table => {
table.dropUnique(['userId', 'storageIdentityKey'], 'sync_states_user_storage_identity')
})
}
}

migrations[SYNC_TRANSFER_MIGRATION] = {
config: { transaction: true },
async up(knex) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ describe('SyncState class method tests', () => {
// Assign a unique value to refNum and valid userId
syncState.userId = 1
syncState.refNum = `testRefNum-${Date.now()}`
syncState.storageIdentityKey = syncState.refNum

// Insert the SyncState into storage
await syncState.updateStorage(ctx.activeStorage)
Expand Down Expand Up @@ -58,6 +59,7 @@ describe('SyncState class method tests', () => {
// Assign a unique value to refNum and valid userId
syncState.userId = 1
syncState.refNum = `testRefNum-${Date.now()}`
syncState.storageIdentityKey = syncState.refNum

// Insert the SyncState into storage
await syncState.updateStorage(ctx.activeStorage)
Expand Down Expand Up @@ -127,6 +129,7 @@ describe('SyncState class method tests', () => {
// Assign a unique value to refNum and valid userId
syncState.userId = 1
syncState.refNum = `testRefNum-${Date.now()}`
syncState.storageIdentityKey = syncState.refNum

// Define the chunk object with all required properties
const chunk: sdk.SyncChunk = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import {
wait
} from '../../src/index.all'
import { Knex } from 'knex'
import { createSyncMap } from '../../src/storage/schema/entities/EntityBase'

const SYNC_STATE_IDENTITY_MIGRATION = '2026-09-30-001 unique sync state per storage identity'

describe('KnexMigrations tests', () => {
jest.setTimeout(99999999)
Expand Down Expand Up @@ -283,6 +286,97 @@ describe('KnexMigrations tests', () => {
expect(txidPlan.some(step => step.key === 'idx_transactions_user_txid')).toBe(true)
})

test('5ab keeps the oldest sync state per storage identity and makes the pair unique', async () => {
const localSQLiteFile = await _tu.newTmpFile('migratesyncstateidentity.sqlite', false, false, false)
const knex = _tu.createLocalSQLite(localSQLiteFile)

try {
await knex.schema.createTable('sync_states', table => {
table.timestamp('updated_at').notNullable().defaultTo(knex.fn.now())
table.increments('syncStateId')
table.integer('userId').notNullable()
table.string('storageIdentityKey', 130).notNullable()
table.string('storageName').notNullable()
table.string('status').notNullable()
table.boolean('init').notNullable()
table.string('refNum', 100).notNullable().unique()
table.text('syncMap').notNullable()
table.dateTime('when')
})
const when = '2026-09-01T00:00:00.000Z'
const row = (syncStateId: number, userId: number, storageIdentityKey: string, storageName: string) => ({
syncStateId,
userId,
storageIdentityKey,
storageName,
status: 'success',
init: true,
refNum: `ref-${syncStateId}`,
syncMap: `{"progress":${syncStateId}}`,
when
})
await knex('sync_states').insert([
row(1, 1, 'raced', 'source'),
row(2, 1, 'raced', 'source'),
row(3, 1, 'raced', 'source'),
row(4, 1, 'renamed', 'old name'),
row(5, 1, 'renamed', 'new name'),
row(6, 2, 'raced', 'source')
])
const restartedAfter = new Date().toISOString()

const source = new KnexMigrations('test', 'sync state identity test', '1'.repeat(64), 1000)
const migration = await source.getMigration(SYNC_STATE_IDENTITY_MIGRATION)
await migration.up(knex)

const rows = await knex('sync_states').orderBy('syncStateId')
expect(rows.map(r => r.syncStateId)).toEqual([1, 4, 6])
expect(rows[0]).toMatchObject({ status: 'success', syncMap: '{"progress":1}', when })
expect(rows[2]).toMatchObject({ status: 'success', syncMap: '{"progress":6}', when })
expect(rows[1]).toMatchObject({
storageName: 'old name',
status: 'unknown',
syncMap: JSON.stringify(createSyncMap()),
when: null
})
expect(Boolean(rows[1].init)).toBe(false)
expect(rows[1].updated_at >= restartedAfter).toBe(true)

await expect(knex('sync_states').insert(row(7, 1, 'raced', 'other name'))).rejects.toThrow(/UNIQUE/)
await knex('sync_states').insert(row(8, 1, 'other source', 'source'))

await migration.down?.(knex)
await knex('sync_states').insert(row(9, 1, 'raced', 'source'))
} finally {
await knex.destroy()
}
})

test('5ac restores the MySQL sync state support index before dropping the unique index', async () => {
const index = jest.fn()
const dropUnique = jest.fn()
const raw = jest
.fn()
.mockResolvedValueOnce([[{ database_type: 'MySQL' }]])
.mockResolvedValueOnce([[]])
const alterTable = jest.fn(
async (
_tableName: string,
callback: (tableBuilder: { index: typeof index; dropUnique: typeof dropUnique }) => void
) => {
callback({ index, dropUnique })
}
)
const knex = { raw, schema: { alterTable } } as unknown as Knex
const source = new KnexMigrations('test', 'MySQL rollback test', '1'.repeat(64), 1000)
const migration = await source.getMigration(SYNC_STATE_IDENTITY_MIGRATION)

await migration.down?.(knex)

expect(index).toHaveBeenCalledWith(['userId'], 'sync_states_userid_foreign')
expect(dropUnique).toHaveBeenCalledWith(['userId', 'storageIdentityKey'], 'sync_states_user_storage_identity')
})

test('5b upgrades only exact untouched managed-change defaults', async () => {
const localSQLiteFile = await _tu.newTmpFile('migratemanagedchange.sqlite', false, false, false)
const knex = _tu.createLocalSQLite(localSQLiteFile)
Expand Down
Loading