Brazen Framework - IndexedDB Storage

IndexedDB storage layer and repositories for Brazen user scripts

Este script não deve ser instalado diretamente. É uma biblioteca destinada a ser incluída por outros scripts através da diretiva de metadados // @require https://update.greasyfork.org/scripts/587030/1884360/Brazen%20Framework%20-%20IndexedDB%20Storage.js

Terá de instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

Terá de instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Terá de instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Terá de instalar uma extensão como Tampermonkey para instalar este script.

Terá de instalar uma extensão de gestão de scripts de utilizador para instalar este script.

(Já tenho um gestor de scripts de utilizador, deixe-me instalá-lo!)

Terá de instalar uma extensão como Stylus para instalar este estilo.

Terá de instalar uma extensão como Stylus para instalar este estilo.

Terá de instalar uma extensão como Stylus para instalar este estilo.

Terá de instalar uma extensão de gestão de estilos de utilizador para instalar este estilo.

Terá de instalar uma extensão de gestão de estilos de utilizador para instalar este estilo.

Terá de instalar uma extensão de gestão de estilos de utilizador para instalar este estilo.

(Já tenho um gestor de estilos de utilizador, deixe-me instalá-lo!)

// ==UserScript==
// @name         Brazen Framework - IndexedDB Storage
// @namespace    brazenvoid
// @version      1.3.1
// @author       brazenvoid
// @license      GPL-3.0-only
// @description  IndexedDB storage layer and repositories for Brazen user scripts
// ==/UserScript==

// Keep at 3+: a brief mistaken bump in development may already have upgraded live DBs.
// IndexedDB cannot open a lower version (VersionError → entire script persistence dies).
// v4: compound `status_addedAt` on download queues for slim peekNextQueued.
// v5: flag async TagEntry.isDiscovered backfill for typed legacy rows (discovery gate).
const IDB_SCHEMA_VERSION = 5

const IDB_STORE_META = 'meta'
const IDB_STORE_SETTINGS = 'settings'
const IDB_STORE_APIS = 'apis'
const IDB_STORE_TAG_TYPES = 'tagTypes'
const IDB_STORE_TAGS = 'tags'
const IDB_STORE_TAG_RULES = 'tagRules'
const IDB_STORE_TAG_RULE_SETS = 'tagRuleSets'
const IDB_STORE_BOOKMARKS = 'bookmarks'
const IDB_STORE_LEDGER = 'ledgerEntries'
const IDB_STORE_DOWNLOAD_RESOLUTION_QUEUE = 'downloadResolutionQueue'
const IDB_STORE_DOWNLOAD_QUEUE = 'downloadQueue'
const IDB_STORE_DOWNLOAD_MANAGER_STATE = 'downloadManagerState'

const IDB_ALL_STORES = [
  IDB_STORE_META,
  IDB_STORE_SETTINGS,
  IDB_STORE_APIS,
  IDB_STORE_TAG_TYPES,
  IDB_STORE_TAGS,
  IDB_STORE_TAG_RULES,
  IDB_STORE_TAG_RULE_SETS,
  IDB_STORE_BOOKMARKS,
  IDB_STORE_LEDGER,
  IDB_STORE_DOWNLOAD_RESOLUTION_QUEUE,
  IDB_STORE_DOWNLOAD_QUEUE,
  IDB_STORE_DOWNLOAD_MANAGER_STATE,
]

const IDB_ROW_STORES = [
  IDB_STORE_TAGS,
  IDB_STORE_TAG_RULES,
  IDB_STORE_BOOKMARKS,
  IDB_STORE_LEDGER,
  IDB_STORE_DOWNLOAD_RESOLUTION_QUEUE,
  IDB_STORE_DOWNLOAD_QUEUE,
]

const IDB_SINGLETON_STORES = [
  IDB_STORE_SETTINGS,
  IDB_STORE_APIS,
  IDB_STORE_TAG_TYPES,
  IDB_STORE_TAG_RULE_SETS,
  IDB_STORE_DOWNLOAD_MANAGER_STATE,
]

const IDB_PUT_MANY_CHUNK = 500

/** Bound for TagRuntime name/id maps — full-table warm is not used. */
const TAG_RUNTIME_CACHE_MAX = 4096

/** Bound for positive ledger membership cache (recent claims / primed hits). */
const LEDGER_POSITIVE_CACHE_MAX = 16384

const LEGACY_SOURCE_LOCAL = 'local'
const LEGACY_SOURCE_GM = 'gm'

/** @type {{source: string, read: function(string, *): *, remove: function(string): void}[]} */
const LEGACY_STORAGE_BACKENDS = [
  {
    source: LEGACY_SOURCE_LOCAL,
    read: readLegacyLocal,
    remove: removeLegacyLocal,
  },
  {
    source: LEGACY_SOURCE_GM,
    read: readLegacyGm,
    remove: removeLegacyGm,
  },
]

/**
 * @param {string} storeKey
 * @param {*} [fallback]
 * @return {*}
 */
function readLegacyLocal(storeKey, fallback = null)
{
  let storedStore = localStorage.getItem(storeKey)
  if (!storedStore || storedStore === '') {
    return fallback
  }
  let parsed = JSON.parse(storedStore)
  if (parsed && typeof parsed === 'object' && 'arrays' in parsed && 'objects' in parsed && 'properties' in parsed) {
    return Utilities.objectFromJSON(storedStore)
  }
  return parsed
}

/**
 * @param {string} storeKey
 */
function removeLegacyLocal(storeKey)
{
  localStorage.removeItem(storeKey)
}

/**
 * @param {string} storeKey
 * @param {*} [fallback]
 * @return {*}
 */
function readLegacyGm(storeKey, fallback = null)
{
  try {
    let value = GM_getValue(storeKey, fallback)
    if (value === undefined) {
      return fallback
    }
    return Utilities.reviveGmStoredValue(value)
  } catch (error) {
    console.log('Failed to read legacy GM storage key:', storeKey, error)
    return fallback
  }
}

/**
 * @param {string} storeKey
 */
function removeLegacyGm(storeKey)
{
  if (typeof GM_deleteValue === 'function') {
    GM_deleteValue(storeKey)
  }
}

/**
 * Whether localStorage or GM still holds pre-IDB Brazen settings/bookmarks keys.
 * @param {string} prefix
 * @param {string|null|undefined} legacyPrefix
 * @return {boolean}
 */
function legacyStorageHasData(prefix, legacyPrefix)
{
  for (let backend of LEGACY_STORAGE_BACKENDS) {
    if (backend.read(prefix + 'settings', null)) {
      return true
    }
    if (legacyPrefix && legacyPrefix !== prefix && backend.read(legacyPrefix + 'settings', null)) {
      return true
    }
  }
  if (readLegacyGm(prefix + 'bookmarks', null)) {
    return true
  }
  if (legacyPrefix && legacyPrefix !== prefix && readLegacyGm(legacyPrefix + 'bookmarks', null)) {
    return true
  }
  return false
}

/**
 * @param {string} prefix
 * @return {Object|null}
 */
function readLegacySettingsAggregate(prefix)
{
  return readLegacyLocal(prefix + 'settings', null) ?? readLegacyGm(prefix + 'settings', null)
}

const TAG_RULESET_PROPERTY_BY_FIELD = {
  'tag-blacklist': 'tagBlacklist',
  'explored-tags-tracker': 'exploredTagsTracker',
  'filename-tag-ignore-list': 'filenameTagIgnoreList',
  'filename-tag-substitutions': 'filenameTagSubstitutions',
  'favourite-tags': 'favouriteTags',
  'disliked-tags': 'dislikedTags',
  'adult-characters': 'highlightAdult',
  'underage-characters': 'highlightUnderage',
  'unknown-age-characters': 'highlightUnknownAge',
}

const TAG_RULE_GROUP_BY_FIELD = {
  'tag-blacklist': 'blacklist',
  'explored-tags-tracker': 'explored',
  'filename-tag-ignore-list': 'filenameIgnore',
  'filename-tag-substitutions': 'filenameSubstitutions',
  'favourite-tags': 'highlightFavourite',
  'disliked-tags': 'highlightDisliked',
  'adult-characters': 'highlightAdult',
  'underage-characters': 'highlightUnderage',
  'unknown-age-characters': 'highlightUnknownAge',
}

const TAG_DOMAIN_FIELD_KEYS = new Set([
  ...Object.keys(TAG_RULESET_PROPERTY_BY_FIELD),
])

/**
 * rule34xxx tagging registry fields (sole attributes on TagEntry; combos in tagRules).
 * @type {Record<string, {group: string, usesTaggingRegistry: boolean, usesCombos?: boolean, soleAttribute?: {root: string, key: string}, substitution?: boolean}>}
 */
const TAG_FIELD_SPEC = {
  'tag-blacklist': {
    group: 'blacklist',
    usesTaggingRegistry: true,
    usesCombos: true,
    soleAttribute: {root: 'compliance', key: 'blacklisted'},
  },
  'explored-tags-tracker': {
    group: 'explored',
    usesTaggingRegistry: true,
    usesCombos: true,
    soleAttribute: {root: 'compliance', key: 'explored'},
  },
  'filename-tag-ignore-list': {
    group: 'filenameIgnore',
    usesTaggingRegistry: true,
    usesCombos: false,
    soleAttribute: {root: 'download', key: 'filenameIgnored'},
  },
  'filename-tag-substitutions': {
    group: 'filenameSubstitutions',
    usesTaggingRegistry: true,
    usesCombos: false,
    substitution: true,
  },
}

const TAGGING_REGISTRY_GROUPS = new Set(
    Object.values(TAG_FIELD_SPEC).map((spec) => spec.group),
)

/**
 * @param {*} row
 * @return {*}
 */
function normalizeTagEntry(row)
{
  if (!row) {
    return row
  }
  let now = Date.now()
  row.compliance = row.compliance ?? {blacklisted: false, explored: false}
  row.download = row.download ?? {filenameIgnored: false, substitution: null}
  row.meta = row.meta ?? {createdAt: now, updatedAt: now}
  if (row.typeEntryId === undefined) {
    row.typeEntryId = null
  }
  // Discovery gate: null = not yet confirmed via discovery / discovery-off resolution.
  if (row.isDiscovered === undefined) {
    row.isDiscovered = null
  }
  return row
}

/**
 * @param {string} rawLine
 * @return {boolean}
 */
function isComboTagLine(rawLine)
{
  let rule = String(rawLine).split('//')[0].trim()
  return rule.includes('&') && !rule.includes('→') && !rule.includes('->')
}

/**
 * @param {string} rawLine
 * @return {{subject: string, replacement: string}|null}
 */
function parseSubstitutionTagLine(rawLine)
{
  let line = String(rawLine).split('//')[0].trim()
  let match = line.match(/^(.+?)\s*(?:→|->)\s*(.+)$/)
  if (!match) {
    return null
  }
  let subject = match[1].trim()
  let replacement = match[2].trim()
  if (!subject || !replacement) {
    return null
  }
  return {subject, replacement}
}

/**
 * @param {*} entry
 * @param {{root: string, key: string}} soleAttribute
 * @return {boolean}
 */
function readTagSoleAttribute(entry, soleAttribute)
{
  if (!entry || !soleAttribute) {
    return false
  }
  let section = entry[soleAttribute.root]
  return !!(section && section[soleAttribute.key])
}

/**
 * @param {*} entry
 * @param {{root: string, key: string}} soleAttribute
 * @param {boolean} value
 */
function writeTagSoleAttribute(entry, soleAttribute, value)
{
  if (!entry[soleAttribute.root]) {
    entry[soleAttribute.root] = soleAttribute.root === 'compliance' ?
        {blacklisted: false, explored: false} :
        {filenameIgnored: false, substitution: null}
  }
  entry[soleAttribute.root][soleAttribute.key] = !!value
}

function dbNameFromScriptPrefix(scriptPrefix)
{
  return String(scriptPrefix).replace(/-$/, '')
}

function fieldKeyToProperty(fieldKey)
{
  return String(fieldKey).replace(/-([a-z0-9])/g, (_, char) => char.toUpperCase())
}

function yieldToBrowser()
{
  return new Promise((resolve) => setTimeout(resolve, 0))
}

function promisifyRequest(request)
{
  return new Promise((resolve, reject) => {
    request.onsuccess = () => resolve(request.result)
    request.onerror = () => reject(request.error)
  })
}

function waitTransaction(tx)
{
  return new Promise((resolve, reject) => {
    tx.oncomplete = () => resolve()
    tx.onerror = () => reject(tx.error)
    tx.onabort = () => reject(tx.error || new Error('Transaction aborted'))
  })
}

class BrazenIndexedDBStorage
{
  constructor(scriptPrefix)
  {
    this._scriptPrefix = scriptPrefix
    this._dbName = dbNameFromScriptPrefix(scriptPrefix)
    this._db = null
    /** @type {Promise<IDBDatabase>|null} Coalesces concurrent open() after close(). */
    this._openPromise = null
    this._available = typeof indexedDB !== 'undefined'
    this._revisionId = null
  }

  get available()
  {
    return this._available
  }

  get dbName()
  {
    return this._dbName
  }

  get database()
  {
    return this._db
  }

  /**
   * @return {Promise<IDBDatabase>}
   */
  async open()
  {
    if (!this._available) {
      throw new Error('IndexedDB is not available')
    }
    if (this._db) {
      return this._db
    }
    if (this._openPromise) {
      return this._openPromise
    }

    this._openPromise = new Promise((resolve, reject) => {
      let request = indexedDB.open(this._dbName, IDB_SCHEMA_VERSION)
      request.onupgradeneeded = (event) => {
        let db = event.target.result
        BrazenIndexedDBStorage._upgradeSchema(db, event.oldVersion, event.target.transaction)
      }
      request.onsuccess = () => resolve(request.result)
      request.onerror = () => reject(request.error)
    }).then((db) => {
      this._db = db
      this._db.onversionchange = () => {
        this.close()
      }
      return this._db
    }).finally(() => {
      this._openPromise = null
    })

    return this._openPromise
  }

  /**
   * Drop the open connection so a navigating / bfcache'd document cannot pin the DB
   * against the next page. Callers reopen via {@link open} (async helpers do this).
   */
  close()
  {
    if (!this._db) {
      return
    }
    try {
      this._db.close()
    } catch (e) {
      // ignore already-closed
    }
    this._db = null
  }

  /**
   * @param {IDBDatabase} db
   * @param {number} oldVersion
   * @param {IDBTransaction|null} [upgradeTx]
   * @private
   */
  static _upgradeSchema(db, oldVersion, upgradeTx = null)
  {
    if (!db.objectStoreNames.contains(IDB_STORE_META)) {
      db.createObjectStore(IDB_STORE_META, {keyPath: 'id'})
    }
    if (!db.objectStoreNames.contains(IDB_STORE_SETTINGS)) {
      db.createObjectStore(IDB_STORE_SETTINGS, {keyPath: 'id'})
    }
    if (!db.objectStoreNames.contains(IDB_STORE_APIS)) {
      db.createObjectStore(IDB_STORE_APIS, {keyPath: 'id'})
    }
    if (!db.objectStoreNames.contains(IDB_STORE_TAG_TYPES)) {
      db.createObjectStore(IDB_STORE_TAG_TYPES, {keyPath: 'id'})
    }
    if (!db.objectStoreNames.contains(IDB_STORE_TAG_RULE_SETS)) {
      db.createObjectStore(IDB_STORE_TAG_RULE_SETS, {keyPath: 'id'})
    }

    if (!db.objectStoreNames.contains(IDB_STORE_TAGS)) {
      let tags = db.createObjectStore(IDB_STORE_TAGS, {keyPath: 'entryId'})
      tags.createIndex('name', 'name', {unique: true})
      tags.createIndex('typeEntryId_name', ['typeEntryId', 'name'], {unique: false})
    }

    if (!db.objectStoreNames.contains(IDB_STORE_TAG_RULES)) {
      let rules = db.createObjectStore(IDB_STORE_TAG_RULES, {keyPath: 'entryId'})
      rules.createIndex('group_sortOrder', ['group', 'sortOrder'], {unique: false})
      rules.createIndex('group_entryId', ['group', 'entryId'], {unique: false})
    }

    if (!db.objectStoreNames.contains(IDB_STORE_BOOKMARKS)) {
      let bookmarks = db.createObjectStore(IDB_STORE_BOOKMARKS, {keyPath: 'entryId'})
      bookmarks.createIndex('sortOrder', 'sortOrder', {unique: false})
      bookmarks.createIndex('label', 'label', {unique: false})
    }

    if (!db.objectStoreNames.contains(IDB_STORE_LEDGER)) {
      let ledger = db.createObjectStore(IDB_STORE_LEDGER, {keyPath: 'entryId'})
      ledger.createIndex('postId', 'postId', {unique: true})
      ledger.createIndex('claimedAt', 'claimedAt', {unique: false})
    }

    if (!db.objectStoreNames.contains(IDB_STORE_DOWNLOAD_RESOLUTION_QUEUE)) {
      let resolutionQueue = db.createObjectStore(IDB_STORE_DOWNLOAD_RESOLUTION_QUEUE, {keyPath: 'itemId'})
      resolutionQueue.createIndex('status', 'status', {unique: false})
      resolutionQueue.createIndex('addedAt', 'addedAt', {unique: false})
      resolutionQueue.createIndex('status_addedAt', ['status', 'addedAt'], {unique: false})
    }

    if (!db.objectStoreNames.contains(IDB_STORE_DOWNLOAD_QUEUE)) {
      let downloadQueue = db.createObjectStore(IDB_STORE_DOWNLOAD_QUEUE, {keyPath: 'itemId'})
      downloadQueue.createIndex('status', 'status', {unique: false})
      downloadQueue.createIndex('addedAt', 'addedAt', {unique: false})
      downloadQueue.createIndex('status_addedAt', ['status', 'addedAt'], {unique: false})
    }

    if (!db.objectStoreNames.contains(IDB_STORE_DOWNLOAD_MANAGER_STATE)) {
      db.createObjectStore(IDB_STORE_DOWNLOAD_MANAGER_STATE, {keyPath: 'id'})
    }

    // v4: compound index on existing queue stores (create stores above already include it).
    if (oldVersion < 4 && upgradeTx) {
      for (let storeName of [IDB_STORE_DOWNLOAD_RESOLUTION_QUEUE, IDB_STORE_DOWNLOAD_QUEUE]) {
        if (!db.objectStoreNames.contains(storeName)) {
          continue
        }
        let store = upgradeTx.objectStore(storeName)
        if (!store.indexNames.contains('status_addedAt')) {
          store.createIndex('status_addedAt', ['status', 'addedAt'], {unique: false})
        }
      }
    }

    // v5: mark async isDiscovered backfill (do not walk tags here — keeps open() paint-friendly).
    if (oldVersion < 5 && upgradeTx && db.objectStoreNames.contains(IDB_STORE_META)) {
      let metaStore = upgradeTx.objectStore(IDB_STORE_META)
      let getReq = metaStore.get('meta')
      getReq.onsuccess = () => {
        let meta = getReq.result
        if (!meta) {
          return
        }
        meta.pendingIsDiscoveredBackfill = true
        metaStore.put(meta)
      }
    }
  }

  /**
   * @return {Promise<boolean>}
   */
  async isHealthy()
  {
    try {
      await this.open()
      for (let storeName of IDB_ALL_STORES) {
        if (!this._db.objectStoreNames.contains(storeName)) {
          return false
        }
      }
      let meta = await this.get(IDB_STORE_META, 'meta')
      if (!meta || meta.setupComplete !== true) {
        return false
      }
      for (let key of ['nextTagEntryId', 'nextTagRuleEntryId', 'nextBookmarkEntryId', 'nextLedgerEntryId']) {
        if (typeof meta[key] !== 'number' || meta[key] < 1) {
          return false
        }
      }
      let settings = await this.get(IDB_STORE_SETTINGS, 'settings')
      let tagRuleSets = await this.get(IDB_STORE_TAG_RULE_SETS, 'tagRuleSets')
      if (!settings || !tagRuleSets) {
        return false
      }
      return true
    } catch (error) {
      console.log('[BrazenIDB] health check failed:', error)
      return false
    }
  }

  /**
   * @return {Promise<void>}
   */
  async deleteDatabase()
  {
    this.close()
    if (!this._available) {
      return
    }
    // Do not resolve on `onblocked` — that fires while other connections (or this tab's
    // just-closed handle) are still releasing. Resolving early + reload aborts the delete.
    await new Promise((resolve, reject) => {
      let request = indexedDB.deleteDatabase(this._dbName)
      request.onsuccess = () => resolve()
      request.onerror = () => reject(request.error ?? new Error('IndexedDB deleteDatabase failed'))
      request.onblocked = () => {
        console.warn('[BrazenIDB] deleteDatabase blocked; waiting for connections to close:', this._dbName)
      }
    })
  }

  /**
   * @param {string[]} storeNames
   * @param {IDBTransactionMode} mode
   * @return {IDBTransaction}
   */
  transaction(storeNames, mode = 'readonly')
  {
    if (!this._db) {
      throw new Error('IndexedDB is closed; call open() first')
    }
    return this._db.transaction(storeNames, mode)
  }

  /**
   * @param {string} store
   * @param {*} pk
   * @return {Promise<*>}
   */
  async get(store, pk)
  {
    await this.open()
    let tx = this.transaction([store], 'readonly')
    return promisifyRequest(tx.objectStore(store).get(pk))
  }

  /**
   * @param {string} store
   * @return {Promise<*[]>}
   */
  async getAll(store)
  {
    await this.open()
    let tx = this.transaction([store], 'readonly')
    return promisifyRequest(tx.objectStore(store).getAll())
  }

  /**
   * @param {string} store
   * @return {Promise<number>}
   */
  async count(store)
  {
    await this.open()
    let tx = this.transaction([store], 'readonly')
    return promisifyRequest(tx.objectStore(store).count())
  }

  /**
   * @param {string} store
   * @param {string} indexName
   * @param {*} key
   * @return {Promise<number>}
   */
  async countByIndex(store, indexName, key)
  {
    await this.open()
    let tx = this.transaction([store], 'readonly')
    return promisifyRequest(tx.objectStore(store).index(indexName).count(IDBKeyRange.only(key)))
  }

  /**
   * @param {string} store
   * @return {Promise<*[]>}
   */
  async getAllKeys(store)
  {
    await this.open()
    let tx = this.transaction([store], 'readonly')
    return promisifyRequest(tx.objectStore(store).getAllKeys())
  }

  /**
   * @param {string} store
   * @param {string} indexName
   * @param {*} key
   * @return {Promise<*[]>}
   */
  async getAllKeysByIndex(store, indexName, key)
  {
    await this.open()
    let tx = this.transaction([store], 'readonly')
    return promisifyRequest(tx.objectStore(store).index(indexName).getAllKeys(IDBKeyRange.only(key)))
  }

  /**
   * @param {string} store
   * @param {string} indexName
   * @param {*} key
   * @return {Promise<*[]>}
   */
  async getAllByIndex(store, indexName, key)
  {
    await this.open()
    let tx = this.transaction([store], 'readonly')
    return promisifyRequest(tx.objectStore(store).index(indexName).getAll(IDBKeyRange.only(key)))
  }

  /**
   * First row on an index matching `predicate`, walking in index order.
   * @param {string} store
   * @param {string} indexName
   * @param {IDBKeyRange|null|undefined} range
   * @param {function(*): boolean} predicate
   * @return {Promise<*|null>}
   */
  async findFirstByIndex(store, indexName, range, predicate)
  {
    await this.open()
    let tx = this.transaction([store], 'readonly')
    let index = tx.objectStore(store).index(indexName)
    // Do not pass `null` into openCursor — some environments treat it as an invalid key.
    let request = range == null ? index.openCursor() : index.openCursor(range)
    return new Promise((resolve, reject) => {
      request.onsuccess = () => {
        let cursor = request.result
        if (!cursor) {
          resolve(null)
          return
        }
        if (predicate(cursor.value)) {
          resolve(cursor.value)
          return
        }
        cursor.continue()
      }
      request.onerror = () => reject(request.error)
    })
  }

  /**
   * @param {string} store
   * @param {*} record
   * @return {Promise<void>}
   */
  async put(store, record)
  {
    await this.open()
    let tx = this.transaction([store], 'readwrite')
    tx.objectStore(store).put(record)
    await waitTransaction(tx)
  }

  /**
   * @param {string} store
   * @param {*} pk
   * @return {Promise<void>}
   */
  async delete(store, pk)
  {
    await this.open()
    let tx = this.transaction([store], 'readwrite')
    tx.objectStore(store).delete(pk)
    await waitTransaction(tx)
  }

  /**
   * @param {string} store
   * @return {Promise<void>}
   */
  async clearStore(store)
  {
    await this.open()
    let tx = this.transaction([store], 'readwrite')
    tx.objectStore(store).clear()
    await waitTransaction(tx)
  }

  /**
   * @param {string} store
   * @param {*[]} records
   * @param {number} chunkSize
   * @return {Promise<void>}
   */
  async putMany(store, records, chunkSize = IDB_PUT_MANY_CHUNK)
  {
    if (!records.length) {
      return
    }
    await this.open()
    for (let index = 0; index < records.length; index += chunkSize) {
      let chunk = records.slice(index, index + chunkSize)
      let tx = this.transaction([store], 'readwrite')
      let objectStore = tx.objectStore(store)
      for (let record of chunk) {
        objectStore.put(record)
      }
      await waitTransaction(tx)
      await yieldToBrowser()
    }
  }

  /**
   * @param {string} store
   * @param {IDBKeyRange|null} range
   * @param {number} limit
   * @return {Promise<{entries: *[], nextCursor: *}>}
   */
  async cursorPage(store, range = null, limit = 50, startAfter = null)
  {
    await this.open()
    let tx = this.transaction([store], 'readonly')
    let objectStore = tx.objectStore(store)
    let effectiveRange = range
    if (startAfter !== null && startAfter !== undefined) {
      effectiveRange = range ?
          IDBKeyRange.bound(range.lower, range.upper, range.lowerOpen, range.upperOpen) :
          IDBKeyRange.lowerBound(startAfter, true)
    }
    let request = objectStore.openCursor(effectiveRange)
    let entries = []
    let nextCursor = null

    await new Promise((resolve, reject) => {
      request.onsuccess = () => {
        let cursor = request.result
        if (!cursor || entries.length >= limit) {
          if (cursor) {
            nextCursor = cursor.key
          }
          resolve()
          return
        }
        entries.push(cursor.value)
        cursor.continue()
      }
      request.onerror = () => reject(request.error)
    })

    return {entries, nextCursor}
  }

  /**
   * @param {string} store
   * @param {number} chunkSize
   * @return {Promise<*[]>}
   */
  async getAllChunked(store, chunkSize = IDB_PUT_MANY_CHUNK)
  {
    let entries = []
    let startAfter = null
    while (true) {
      let page = await this.cursorPage(store, null, chunkSize, startAfter)
      entries.push(...page.entries)
      if (!page.nextCursor) {
        break
      }
      startAfter = page.nextCursor
      await yieldToBrowser()
    }
    return entries
  }

  /**
   * @return {Promise<number>}
   */
  async getRevisionId()
  {
    let meta = await this.getMeta()
    return meta?.revisionId ?? 0
  }

  /**
   * @return {Promise<object>}
   */
  async getMeta()
  {
    return (await this.get(IDB_STORE_META, 'meta')) ?? null
  }

  /**
   * @param {Function} fn
   * @return {Promise<*>}
   */
  async guardedWrite(fn)
  {
    let revisionBefore = await this.getRevisionId()
    let result = await fn()
    let revisionAfter = await this.getRevisionId()
    if (revisionBefore !== revisionAfter) {
      this._revisionId = revisionAfter
    }
    return result
  }

  /**
   * @return {Promise<object>}
   */
  async createDefaultMeta()
  {
    return {
      id: 'meta',
      revisionId: Utilities.generateId(),
      schemaVersion: IDB_SCHEMA_VERSION,
      scriptPrefix: this._scriptPrefix,
      setupComplete: false,
      setupInProgress: false,
      schemaMigrationState: 'idle',
      pendingIsDiscoveredBackfill: false,
      compilePendingGroups: [],
      nextTagEntryId: 1,
      nextTagRuleEntryId: 1,
      nextBookmarkEntryId: 1,
      nextLedgerEntryId: 1,
      migrationSources: [],
    }
  }

  /**
   * Chunked post-open backfill for {@link TagEntry.isDiscovered} when meta.pendingIsDiscoveredBackfill.
   * Yields between pages so dock migration UI can paint/animate.
   * @param {function(string): void|null} [onProgress]
   * @return {Promise<boolean>} True when a pending backfill ran.
   */
  async runPendingIsDiscoveredBackfill(onProgress = null)
  {
    await this.open()
    let meta = await this.getMeta()
    if (!meta?.pendingIsDiscoveredBackfill) {
      return false
    }
    if (typeof onProgress === 'function') {
      onProgress('Migrating tags…')
    }
    let startAfter = null
    let chunkSize = 100
    while (true) {
      let page = await this.cursorPage(IDB_STORE_TAGS, null, chunkSize, startAfter)
      if (!page.entries.length) {
        break
      }
      let toPut = []
      for (let row of page.entries) {
        normalizeTagEntry(row)
        // Legacy typed rows → discovered; untyped stay null.
        let next = row.typeEntryId != null ? true : null
        if (row.isDiscovered !== next) {
          row.isDiscovered = next
          toPut.push(row)
        }
      }
      if (toPut.length) {
        await this.putMany(IDB_STORE_TAGS, toPut, chunkSize)
      }
      if (!page.nextCursor) {
        break
      }
      startAfter = page.nextCursor
      await yieldToBrowser()
    }
    meta = await this.getMeta()
    if (meta) {
      meta.pendingIsDiscoveredBackfill = false
      await this.put(IDB_STORE_META, meta)
    }
    return true
  }

  /**
   * @return {Promise<object>}
   */
  async createEmptySettingsDocument()
  {
    return {id: 'settings'}
  }

  /**
   * @return {Promise<object>}
   */
  async createEmptyApisDocument()
  {
    return {id: 'apis', entries: []}
  }

  /**
   * @return {Promise<object>}
   */
  async createEmptyTagTypesDocument()
  {
    return {id: 'tagTypes', entries: []}
  }

  /**
   * @return {Promise<object>}
   */
  async createEmptyTagRuleSetsDocument()
  {
    return {id: 'tagRuleSets'}
  }
}

class MetaRepository
{
  /**
   * @param {BrazenIndexedDBStorage} storage
   * @param {function(number|string): void|null} [onRevisionBump]
   */
  constructor(storage, onRevisionBump = null)
  {
    this._storage = storage
    this._onRevisionBump = onRevisionBump
  }

  async get()
  {
    return this._storage.getMeta()
  }

  async put(meta)
  {
    await this._storage.put(IDB_STORE_META, meta)
  }

  async bumpRevision()
  {
    let meta = await this.get()
    if (!meta) {
      return 0
    }
    meta.revisionId = Utilities.generateId()
    await this.put(meta)
    this._storage._revisionId = meta.revisionId
    if (this._onRevisionBump) {
      this._onRevisionBump(meta.revisionId)
    }
    return meta.revisionId
  }

  async waitForSetupComplete(timeoutMs = 120000)
  {
    let started = Date.now()
    while (Date.now() - started < timeoutMs) {
      let meta = await this.get()
      if (meta?.setupComplete && !meta?.setupInProgress) {
        return true
      }
      if (meta?.setupInProgress) {
        await Utilities.sleep(200)
        continue
      }
      return false
    }
    throw new Error('Timed out waiting for setup to complete')
  }

  async beginSetup()
  {
    let meta = await this.get() ?? await this._storage.createDefaultMeta()
    if (meta.setupInProgress) {
      await this.waitForSetupComplete()
      return this.get()
    }
    meta.setupInProgress = true
    meta.schemaMigrationState = 'idle'
    await this.put(meta)
    return meta
  }

  async completeSetup(sources = [])
  {
    let meta = await this.get() ?? await this._storage.createDefaultMeta()
    meta.setupComplete = true
    meta.setupInProgress = false
    meta.migratedAt = Date.now()
    meta.migrationSources = sources
    await this.put(meta)
    return meta
  }

  async markCompilePending(groups)
  {
    let meta = await this.get()
    if (!meta) {
      return
    }
    let pending = new Set(meta.compilePendingGroups ?? [])
    for (let group of groups) {
      pending.add(group)
    }
    meta.compilePendingGroups = [...pending]
    await this.put(meta)
  }

  async clearCompilePending(groups)
  {
    let meta = await this.get()
    if (!meta) {
      return
    }
    let pending = new Set(meta.compilePendingGroups ?? [])
    for (let group of groups) {
      pending.delete(group)
    }
    meta.compilePendingGroups = [...pending]
    await this.put(meta)
  }

  async allocateEntryId(counterKey)
  {
    let meta = await this.get()
    if (!meta) {
      throw new Error('Meta record missing')
    }
    let value = meta[counterKey] ?? 1
    meta[counterKey] = value + 1
    await this.put(meta)
    return value
  }
}

class SettingsRepository
{
  /**
   * @param {BrazenIndexedDBStorage} storage
   */
  constructor(storage)
  {
    this._storage = storage
  }

  async getDocument()
  {
    return (await this._storage.get(IDB_STORE_SETTINGS, 'settings')) ?? {id: 'settings'}
  }

  async putDocument(doc)
  {
    doc.id = 'settings'
    await this._storage.put(IDB_STORE_SETTINGS, doc)
  }

  /**
   * @param {string} fieldKey
   * @return {Promise<{value: *, optimized: *, updatedAt: number}|null>}
   */
  async getField(fieldKey)
  {
    let doc = await this.getDocument()
    let property = fieldKeyToProperty(fieldKey)
    return doc[property] ?? null
  }

  async getValue(fieldKey)
  {
    let field = await this.getField(fieldKey)
    return field?.value ?? null
  }

  async getOptimized(fieldKey)
  {
    let field = await this.getField(fieldKey)
    return field?.optimized ?? null
  }

  /**
   * @param {string} fieldKey
   * @param {*} value
   * @param {*} optimized
   * @return {Promise<void>}
   */
  async putField(fieldKey, value, optimized = null)
  {
    let doc = await this.getDocument()
    let property = fieldKeyToProperty(fieldKey)
    doc[property] = {
      value,
      optimized,
      updatedAt: Date.now(),
    }
    await this.putDocument(doc)
  }
}

class BookmarkRepository
{
  /**
   * @param {BrazenIndexedDBStorage} storage
   * @param {MetaRepository} metaRepo
   * @param {function(string): void|null} onChange
   */
  constructor(storage, metaRepo, onChange = null)
  {
    this._storage = storage
    this._metaRepo = metaRepo
    this._onChange = onChange
  }

  /**
   * @private
   */
  _notifyChange()
  {
    if (this._onChange) {
      this._onChange('bookmarks')
    }
  }

  async listAll()
  {
    let rows = await this._storage.getAll(IDB_STORE_BOOKMARKS)
    return rows.sort((left, right) => (left.sortOrder ?? 0) - (right.sortOrder ?? 0))
  }

  async get(entryId)
  {
    return this._storage.get(IDB_STORE_BOOKMARKS, entryId)
  }

  async add(entry)
  {
    let entryId = entry.entryId ?? await this._metaRepo.allocateEntryId('nextBookmarkEntryId')
    let now = Date.now()
    let row = {
      entryId,
      label: entry.label ?? '',
      tags: entry.tags ?? '',
      url: entry.url ?? '',
      sortOrder: entry.sortOrder ?? 0,
      createdAt: entry.createdAt ?? now,
      updatedAt: now,
    }
    await this._storage.put(IDB_STORE_BOOKMARKS, row)
    await this._metaRepo.bumpRevision()
    this._notifyChange()
    return row
  }

  async update(row)
  {
    row.updatedAt = Date.now()
    await this._storage.put(IDB_STORE_BOOKMARKS, row)
    await this._metaRepo.bumpRevision()
    this._notifyChange()
    return row
  }

  async remove(entryId)
  {
    await this._storage.delete(IDB_STORE_BOOKMARKS, entryId)
    await this._metaRepo.bumpRevision()
    this._notifyChange()
  }

  async replaceAll(rows)
  {
    await this._storage.clearStore(IDB_STORE_BOOKMARKS)
    let sortOrder = 0
    let normalized = []
    for (let row of rows) {
      let entryId = row.entryId ?? await this._metaRepo.allocateEntryId('nextBookmarkEntryId')
      normalized.push({
        entryId,
        label: row.label ?? '',
        tags: row.tags ?? '',
        url: row.url ?? '',
        sortOrder: row.sortOrder ?? sortOrder++,
        createdAt: row.createdAt ?? Date.now(),
        updatedAt: row.updatedAt ?? Date.now(),
      })
    }
    await this._storage.putMany(IDB_STORE_BOOKMARKS, normalized)
    await this._metaRepo.bumpRevision()
    this._notifyChange()
  }
}

class LedgerRepository
{
  /**
   * @param {BrazenIndexedDBStorage} storage
   * @param {MetaRepository} metaRepo
   * @param {function(string): void|null} onChange
   */
  constructor(storage, metaRepo, onChange = null)
  {
    this._storage = storage
    this._metaRepo = metaRepo
    this._onChange = onChange
  }

  /**
   * @private
   */
  _notifyChange()
  {
    if (this._onChange) {
      this._onChange('ledger')
    }
  }

  async has(postId)
  {
    await this._storage.open()
    let tx = this._storage.transaction([IDB_STORE_LEDGER], 'readonly')
    let index = tx.objectStore(IDB_STORE_LEDGER).index('postId')
    return !!(await promisifyRequest(index.get(String(postId))))
  }

  /**
   * Batch membership check via the unique `postId` index (no getAll).
   * @param {string[]} postIds
   * @return {Promise<Set<string>>} ids that exist in the ledger
   */
  async hasMany(postIds)
  {
    let unique = [...new Set(postIds.map((id) => String(id).trim()).filter(Boolean))]
    let hits = new Set()
    if (!unique.length) {
      return hits
    }
    await this._storage.open()
    let tx = this._storage.transaction([IDB_STORE_LEDGER], 'readonly')
    let index = tx.objectStore(IDB_STORE_LEDGER).index('postId')
    await Promise.all(unique.map(async (postId) => {
      let row = await promisifyRequest(index.get(postId))
      if (row) {
        hits.add(postId)
      }
    }))
    return hits
  }

  async claim(postId)
  {
    let normalized = String(postId).trim()
    if (!normalized) {
      return false
    }
    if (await this.has(normalized)) {
      return false
    }
    let entryId = await this._metaRepo.allocateEntryId('nextLedgerEntryId')
    await this._storage.put(IDB_STORE_LEDGER, {
      entryId,
      postId: normalized,
      claimedAt: Date.now(),
    })
    await this._metaRepo.bumpRevision()
    this._notifyChange()
    return true
  }

  async mergeRows(rows)
  {
    await this._storage.open()
    for (let row of rows) {
      let postId = String(row.postId ?? row.id ?? '').trim()
      if (!postId) {
        continue
      }
      let tx = this._storage.transaction([IDB_STORE_LEDGER], 'readonly')
      let existing = await promisifyRequest(tx.objectStore(IDB_STORE_LEDGER).index('postId').get(postId))
      if (!existing || (row.claimedAt ?? 0) >= (existing.claimedAt ?? 0)) {
        if (!row.entryId) {
          row.entryId = await this._metaRepo.allocateEntryId('nextLedgerEntryId')
        }
        row.postId = postId
        await this._storage.put(IDB_STORE_LEDGER, row)
      }
    }
    await this._metaRepo.bumpRevision()
    this._notifyChange()
  }

  async replaceAll(rows)
  {
    await this._storage.clearStore(IDB_STORE_LEDGER)
    let normalized = []
    for (let row of rows) {
      let postId = String(row.postId ?? row.id ?? '').trim()
      if (!postId) {
        continue
      }
      let entryId = row.entryId ?? await this._metaRepo.allocateEntryId('nextLedgerEntryId')
      normalized.push({
        entryId,
        postId,
        claimedAt: row.claimedAt ?? Date.now(),
      })
    }
    await this._storage.putMany(IDB_STORE_LEDGER, normalized)
    await this._metaRepo.bumpRevision()
    this._notifyChange()
  }
}

/**
 * @return {object}
 */
function createDefaultDownloadManagerState()
{
  return {
    id: 'state',
    paused: true,
    resolutionBlocked: false,
    resolutionBlockedItemId: null,
    /**
     * Per-lane human-interaction blocks.
     * Each lane entry is `{ itemId, promptTabId, openUrl, at }` or `null`.
     */
    humanInteraction: {resolution: null, download: null},
    processingTabId: null,
    processingHeartbeatAt: 0,
    lastResolutionInitiationAt: 0,
    lastDownloadInitiationAt: 0,
    tagDiscoveryEnabled: false,
    tagDiscoveryPanelTabId: null,
    discoveryPanelTags: null,
    discoveryPanelKnownTags: null,
    /** `'unknown'` | `'ignoredPins'` | null — why the discovery panel is open. */
    discoveryReviewMode: null,
    pendingImmediateDownload: null,
    completedResolutionCount: 0,
    completedDownloadCount: 0,
  }
}

/** Terminal resolution-queue statuses (must match Download Manager). */
const RESOLUTION_QUEUE_TERMINAL_STATUSES = ['failed', 'done']
const RESOLUTION_QUEUE_TERMINAL_SET = new Set(RESOLUTION_QUEUE_TERMINAL_STATUSES)
/** Non-terminal resolution-queue statuses used for key listing. */
const RESOLUTION_QUEUE_ACTIVE_STATUSES = ['queued', 'resolving', 'tagReview']
/** Terminal download-queue statuses (must match Download Manager). */
const DOWNLOAD_QUEUE_TERMINAL_STATUSES = ['done', 'skipped', 'duplicate', 'failed']
const DOWNLOAD_QUEUE_TERMINAL_SET = new Set(DOWNLOAD_QUEUE_TERMINAL_STATUSES)
/** Non-terminal download-queue statuses used for key listing. */
const DOWNLOAD_QUEUE_ACTIVE_STATUSES = ['queued', 'downloading']

/**
 * @param {BrazenIndexedDBStorage} storage
 * @param {string} store
 * @param {string[]} activeStatuses
 * @param {Set<string>} terminalSet
 * @return {Promise<number>}
 */
async function countActiveQueueRows(storage, store, activeStatuses, terminalSet)
{
  try {
    let active = 0
    for (let status of activeStatuses) {
      active += await storage.countByIndex(store, 'status', status)
    }
    return active
  } catch (error) {
    let rows = await storage.getAll(store)
    return rows.filter((row) => !terminalSet.has(row.status)).length
  }
}

/**
 * @param {BrazenIndexedDBStorage} storage
 * @param {string} store
 * @param {string[]} activeStatuses
 * @param {Set<string>} terminalSet
 * @return {Promise<string[]>}
 */
async function listActiveQueueItemIds(storage, store, activeStatuses, terminalSet)
{
  try {
    let ids = []
    for (let status of activeStatuses) {
      let keys = await storage.getAllKeysByIndex(store, 'status', status)
      for (let key of keys) {
        ids.push(String(key))
      }
    }
    return ids
  } catch (error) {
    let rows = await storage.getAll(store)
    return rows.filter((row) => !terminalSet.has(row.status)).map((row) => String(row.itemId))
  }
}

/**
 * Oldest `queued` row. Prefers compound `status_addedAt` (one row) then status index fallback.
 * @param {BrazenIndexedDBStorage} storage
 * @param {string} store
 * @return {Promise<object|null>}
 */
async function peekNextQueuedRow(storage, store)
{
  try {
    await storage.open()
    let tx = storage.transaction([store], 'readonly')
    let objectStore = tx.objectStore(store)
    if (objectStore.indexNames.contains('status_addedAt')) {
      let index = objectStore.index('status_addedAt')
      let range = IDBKeyRange.bound(['queued', -Infinity], ['queued', Infinity])
      let row = await new Promise((resolve, reject) => {
        let request = index.openCursor(range)
        request.onsuccess = () => {
          let cursor = request.result
          resolve(cursor ? cursor.value : null)
        }
        request.onerror = () => reject(request.error)
      })
      await waitTransaction(tx)
      return row
    }
    // Compound index missing (pre-v4 DB mid-upgrade) — fall back without holding this tx.
    await waitTransaction(tx)
    let rows = await storage.getAllByIndex(store, 'status', 'queued')
    if (!rows.length) {
      return null
    }
    let best = rows[0]
    for (let index = 1; index < rows.length; index++) {
      let row = rows[index]
      if ((row.addedAt ?? 0) < (best.addedAt ?? 0)) {
        best = row
      }
    }
    return best
  } catch (error) {
    let rows = await storage.getAll(store)
    rows.sort((left, right) => (left.addedAt ?? 0) - (right.addedAt ?? 0))
    return rows.find((row) => row.status === 'queued') ?? null
  }
}

/**
 * Delete all rows whose `status` is terminal (keys only — avoids loading fat payloads).
 * @param {BrazenIndexedDBStorage} storage
 * @param {string} store
 * @param {string[]} terminalStatuses
 * @return {Promise<number>} number of keys deleted
 */
async function pruneTerminalQueueRows(storage, store, terminalStatuses)
{
  let deleted = 0
  for (let status of terminalStatuses) {
    let keys = await storage.getAllKeysByIndex(store, 'status', status)
    if (!keys.length) {
      continue
    }
    await storage.open()
    let tx = storage.transaction([store], 'readwrite')
    let objectStore = tx.objectStore(store)
    for (let key of keys) {
      objectStore.delete(key)
      deleted++
    }
    await waitTransaction(tx)
  }
  return deleted
}

/**
 * Shared queue repository for resolution + download stores.
 */
class DownloadQueueRepositoryBase
{
  /**
   * @param {BrazenIndexedDBStorage} storage
   * @param {MetaRepository} metaRepo
   * @param {function(string): void|null} onChange
   * @param {{store: string, changeSource: string, terminalStatuses: string[], terminalSet: Set<string>, activeStatuses: string[]}} options
   */
  constructor(storage, metaRepo, onChange, options)
  {
    this._storage = storage
    this._metaRepo = metaRepo
    this._onChange = onChange
    this._store = options.store
    this._changeSource = options.changeSource
    this._terminalStatuses = options.terminalStatuses
    this._terminalSet = options.terminalSet
    this._activeStatuses = options.activeStatuses
  }

  /**
   * @private
   */
  _notifyChange()
  {
    if (this._onChange) {
      this._onChange(this._changeSource)
    }
  }

  async listAll()
  {
    let rows = await this._storage.getAll(this._store)
    return rows.sort((left, right) => (left.addedAt ?? 0) - (right.addedAt ?? 0))
  }

  /**
   * Non-terminal row count without materializing fat queue payloads.
   * @return {Promise<number>}
   */
  async countActive()
  {
    return countActiveQueueRows(this._storage, this._store, this._activeStatuses, this._terminalSet)
  }

  /**
   * Rows for a single status (index lookup — not a full-table scan).
   * @param {string} status
   * @return {Promise<object[]>}
   */
  async listByStatus(status)
  {
    return this._storage.getAllByIndex(this._store, 'status', status)
  }

  /**
   * Primary keys for non-terminal rows (no row bodies).
   * @return {Promise<string[]>}
   */
  async listActiveItemIds()
  {
    return listActiveQueueItemIds(this._storage, this._store, this._activeStatuses, this._terminalSet)
  }

  /**
   * Oldest `queued` row by `addedAt`, or null.
   * @return {Promise<object|null>}
   */
  async peekNextQueued()
  {
    return peekNextQueuedRow(this._storage, this._store)
  }

  /**
   * Remove terminal rows left behind after crashes (no per-item finally).
   * @return {Promise<number>}
   */
  async pruneTerminal()
  {
    let deleted = await pruneTerminalQueueRows(this._storage, this._store, this._terminalStatuses)
    if (deleted) {
      this._notifyChange()
    }
    return deleted
  }

  async get(itemId)
  {
    return this._storage.get(this._store, itemId)
  }

  async put(row)
  {
    await this._storage.put(this._store, row)
    this._notifyChange()
    return row
  }

  async remove(itemId)
  {
    await this._storage.delete(this._store, itemId)
    this._notifyChange()
  }

  async clearAll()
  {
    await this._storage.clearStore(this._store)
    this._notifyChange()
  }
}

class DownloadResolutionQueueRepository extends DownloadQueueRepositoryBase
{
  /**
   * @param {BrazenIndexedDBStorage} storage
   * @param {MetaRepository} metaRepo
   * @param {function(string): void|null} onChange
   */
  constructor(storage, metaRepo, onChange = null)
  {
    super(storage, metaRepo, onChange, {
      store: IDB_STORE_DOWNLOAD_RESOLUTION_QUEUE,
      changeSource: 'downloadResolutionQueue',
      terminalStatuses: RESOLUTION_QUEUE_TERMINAL_STATUSES,
      terminalSet: RESOLUTION_QUEUE_TERMINAL_SET,
      activeStatuses: RESOLUTION_QUEUE_ACTIVE_STATUSES,
    })
  }
}

class DownloadQueueRepository extends DownloadQueueRepositoryBase
{
  /**
   * @param {BrazenIndexedDBStorage} storage
   * @param {MetaRepository} metaRepo
   * @param {function(string): void|null} onChange
   */
  constructor(storage, metaRepo, onChange = null)
  {
    super(storage, metaRepo, onChange, {
      store: IDB_STORE_DOWNLOAD_QUEUE,
      changeSource: 'downloadQueue',
      terminalStatuses: DOWNLOAD_QUEUE_TERMINAL_STATUSES,
      terminalSet: DOWNLOAD_QUEUE_TERMINAL_SET,
      activeStatuses: DOWNLOAD_QUEUE_ACTIVE_STATUSES,
    })
  }
}

class DownloadManagerStateRepository
{
  /**
   * @param {BrazenIndexedDBStorage} storage
   * @param {MetaRepository} metaRepo
   * @param {function(string): void|null} onChange
   */
  constructor(storage, metaRepo, onChange = null)
  {
    this._storage = storage
    this._metaRepo = metaRepo
    this._onChange = onChange
  }

  /**
   * @private
   */
  _notifyChange()
  {
    if (this._onChange) {
      this._onChange('downloadManagerState')
    }
  }

  async get()
  {
    return (await this._storage.get(IDB_STORE_DOWNLOAD_MANAGER_STATE, 'state')) ?? createDefaultDownloadManagerState()
  }

  async put(state)
  {
    state.id = 'state'
    await this._storage.put(IDB_STORE_DOWNLOAD_MANAGER_STATE, state)
    this._notifyChange()
    return state
  }

  async reset()
  {
    return this.put(createDefaultDownloadManagerState())
  }
}

class TagRuleRepository
{
  /**
   * @param {BrazenIndexedDBStorage} storage
   * @param {MetaRepository} metaRepo
   */
  constructor(storage, metaRepo)
  {
    this._storage = storage
    this._metaRepo = metaRepo
  }

  async listByGroup(group)
  {
    await this._storage.open()
    let tx = this._storage.transaction([IDB_STORE_TAG_RULES], 'readonly')
    let index = tx.objectStore(IDB_STORE_TAG_RULES).index('group_sortOrder')
    let range = IDBKeyRange.bound([group, 0], [group, Number.MAX_SAFE_INTEGER])
    return promisifyRequest(index.getAll(range))
  }

  /**
   * @param {string} group
   * @param {number|null} cursor
   * @param {number} limit
   * @return {Promise<{entries: *[], nextCursor: number|null}>}
   */
  async listPaginated(group, cursor = null, limit = 50)
  {
    await this._storage.open()
    let tx = this._storage.transaction([IDB_STORE_TAG_RULES], 'readonly')
    let index = tx.objectStore(IDB_STORE_TAG_RULES).index('group_entryId')
    let lower = cursor === null ? [group, 0] : [group, cursor]
    let range = IDBKeyRange.bound(lower, [group, Number.MAX_SAFE_INTEGER])
    let request = index.openCursor(range)
    let entries = []
    let nextCursor = null

    await new Promise((resolve, reject) => {
      request.onsuccess = () => {
        let row = request.result
        if (!row || entries.length >= limit) {
          if (row) {
            nextCursor = row.value.entryId
          }
          resolve()
          return
        }
        entries.push(row.value)
        row.continue()
      }
      request.onerror = () => reject(request.error)
    })

    return {entries, nextCursor}
  }

  /**
   * @param {string} group
   * @param {string} tagName
   * @return {Promise<boolean>}
   */
  async hasSoleTagRule(group, tagName)
  {
    let rules = await this.listByGroup(group)
    return rules.some((rule) => TagRuleRepository._isSoleTagRule(rule, tagName))
  }

  async addRule(group, rule)
  {
    let entryId = rule.entryId ?? await this._metaRepo.allocateEntryId('nextTagRuleEntryId')
    let row = {
      entryId,
      group,
      sortOrder: rule.sortOrder ?? 0,
      rawLine: rule.rawLine ?? '',
      mode: rule.mode ?? 'tags',
      selectors: rule.selectors ?? null,
      expression: rule.expression ?? null,
      comment: rule.comment ?? '',
      updatedAt: Date.now(),
    }
    await this._storage.put(IDB_STORE_TAG_RULES, row)
    await this._metaRepo.markCompilePending([group])
    await this._metaRepo.bumpRevision()
    return row
  }

  async removeRule(entryId, group = null)
  {
    await this._storage.delete(IDB_STORE_TAG_RULES, entryId)
    if (group) {
      await this._metaRepo.markCompilePending([group])
    }
    await this._metaRepo.bumpRevision()
  }

  async removeAllInGroup(group)
  {
    let rules = await this.listByGroup(group)
    for (let rule of rules) {
      await this._storage.delete(IDB_STORE_TAG_RULES, rule.entryId)
    }
    if (rules.length) {
      await this._metaRepo.markCompilePending([group])
      await this._metaRepo.bumpRevision()
    }
  }

  async clearAll()
  {
    await this._storage.guardedWrite(async () => {
      await this._storage.clearStore(IDB_STORE_TAG_RULES)
      await this._metaRepo.bumpRevision()
    })
  }

  async removeSoleTagRules(group, tagName)
  {
    let rules = await this.listByGroup(group)
    let removed = false
    for (let rule of rules) {
      if (TagRuleRepository._isSoleTagRule(rule, tagName)) {
        await this.removeRule(rule.entryId, group)
        removed = true
      }
    }
    if (removed) {
      await this._metaRepo.markCompilePending([group])
    }
  }

  /**
   * @param {*} rule
   * @param {string} tagName
   * @return {boolean}
   * @private
   */
  static _isSoleTagRule(rule, tagName)
  {
    let raw = String(rule.rawLine ?? '').split('//')[0].trim()
    if (!raw || raw.includes('&') || raw.includes('|')) {
      return false
    }
    return raw.trim() === tagName
  }
}

class TagRepository
{
  /**
   * @param {BrazenIndexedDBStorage} storage
   * @param {MetaRepository} metaRepo
   * @param {TagRuleRepository} tagRuleRepo
   */
  constructor(storage, metaRepo, tagRuleRepo)
  {
    this._storage = storage
    this._metaRepo = metaRepo
    this._tagRuleRepo = tagRuleRepo
  }

  async getByName(name)
  {
    await this._storage.open()
    let tx = this._storage.transaction([IDB_STORE_TAGS], 'readonly')
    let row = await promisifyRequest(tx.objectStore(IDB_STORE_TAGS).index('name').get(name))
    return normalizeTagEntry(row)
  }

  /**
   * @param {string[]} names
   * @return {Promise<Map<string, *>>} name → entry for rows that exist
   */
  async getByNames(names)
  {
    let unique = [...new Set(names.filter(Boolean))]
    let map = new Map()
    if (!unique.length) {
      return map
    }
    await this._storage.open()
    let tx = this._storage.transaction([IDB_STORE_TAGS], 'readonly')
    let index = tx.objectStore(IDB_STORE_TAGS).index('name')
    await Promise.all(unique.map(async (name) => {
      let row = await promisifyRequest(index.get(name))
      if (row) {
        map.set(name, normalizeTagEntry(row))
      }
    }))
    return map
  }

  async getByEntryId(entryId)
  {
    return normalizeTagEntry(await this._storage.get(IDB_STORE_TAGS, entryId))
  }

  /**
   * @param {number[]} entryIds
   * @return {Promise<Map<number, *>>}
   */
  async getByEntryIds(entryIds)
  {
    let unique = [...new Set(entryIds.filter((id) => id != null))]
    let map = new Map()
    if (!unique.length) {
      return map
    }
    await Promise.all(unique.map(async (entryId) => {
      let row = await this._storage.get(IDB_STORE_TAGS, entryId)
      if (row) {
        map.set(entryId, normalizeTagEntry(row))
      }
    }))
    return map
  }

  async putTag(row)
  {
    normalizeTagEntry(row)
    row.meta.updatedAt = Date.now()
    await this._storage.guardedWrite(async () => {
      await this._storage.put(IDB_STORE_TAGS, row)
      await this._metaRepo.bumpRevision()
    })
    return row
  }

  async registerTag(name, typeEntryId = null)
  {
    let existing = await this.getByName(name)
    if (existing) {
      if (typeEntryId != null && existing.typeEntryId == null) {
        existing.typeEntryId = typeEntryId
      }
      existing.meta.updatedAt = Date.now()
      return this.putTag(existing)
    }
    let entryId = await this._metaRepo.allocateEntryId('nextTagEntryId')
    let now = Date.now()
    let row = normalizeTagEntry({
      entryId,
      name,
      typeEntryId,
      isDiscovered: null,
      compliance: {
        blacklisted: false,
        explored: false,
      },
      download: {
        filenameIgnored: false,
        substitution: null,
      },
      meta: {
        createdAt: now,
        updatedAt: now,
      },
    })
    await this._storage.guardedWrite(async () => {
      await this._storage.put(IDB_STORE_TAGS, row)
      await this._metaRepo.bumpRevision()
    })
    return row
  }

  /**
   * Resolves a canonical tag type name to its {@link IDB_STORE_TAG_TYPES} entryId.
   * Alias entries (e.g. site "artist" → canonical author) return the canonical id.
   *
   * @param {string|null|undefined} typeName
   * @return {Promise<number|null>}
   */
  async resolveCanonicalTypeEntryId(typeName)
  {
    if (!typeName) {
      return null
    }
    let doc = await this._storage.get(IDB_STORE_TAG_TYPES, 'tagTypes')
    let entries = doc?.entries ?? []
    let entry = entries.find((row) => row.name === typeName)
    if (!entry) {
      return null
    }
    if (entry.aliasOfEntryIds?.length) {
      return entry.aliasOfEntryIds[0] ?? null
    }
    return entry.entryId ?? null
  }

  /**
   * @param {{cursor?: number|string|null, limit?: number, typeEntryId?: number|null, namePrefix?: string|null}} options
   *        For `typeEntryId` pages, `cursor` is the last included tag `name` (exclusive start for the next page).
   * @return {Promise<{entries: *[], nextCursor?: number|string}>}
   */
  async listTags(options = {})
  {
    let limit = options.limit ?? 50
    let store = IDB_STORE_TAGS
    if (options.namePrefix) {
      let prefix = String(options.namePrefix)
      let range = IDBKeyRange.bound(prefix, prefix + '\uffff')
      let page = await this._storage.cursorPage(store, range, limit, options.cursor ?? null)
      return {entries: page.entries, nextCursor: page.nextCursor ?? undefined}
    }
    if (options.typeEntryId !== null && options.typeEntryId !== undefined) {
      await this._storage.open()
      let tx = this._storage.transaction([store], 'readonly')
      let index = tx.objectStore(store).index('typeEntryId_name')
      let typeId = options.typeEntryId
      let range = options.cursor != null && options.cursor !== ''
          ? IDBKeyRange.bound([typeId, String(options.cursor)], [typeId, '\uffff'], true, false)
          : IDBKeyRange.bound([typeId, ''], [typeId, '\uffff'])
      let request = index.openCursor(range)
      let entries = []
      let nextCursor = null
      await new Promise((resolve, reject) => {
        request.onsuccess = () => {
          let row = request.result
          if (!row) {
            resolve()
            return
          }
          if (entries.length >= limit) {
            nextCursor = entries[entries.length - 1]?.name ?? null
            resolve()
            return
          }
          entries.push(row.value)
          row.continue()
        }
        request.onerror = () => reject(request.error)
      })
      return {entries, nextCursor: nextCursor ?? undefined}
    }
    let page = await this._storage.cursorPage(store, null, limit, options.cursor ?? null)
    return {entries: page.entries, nextCursor: page.nextCursor ?? undefined}
  }

  async clearAll()
  {
    await this._storage.guardedWrite(async () => {
      await this._storage.clearStore(IDB_STORE_TAGS)
      await this._metaRepo.bumpRevision()
    })
  }

  async resetCompiledRuleSets()
  {
    await this._storage.guardedWrite(async () => {
      await this.putTagRuleSetsDocument(await this._storage.createEmptyTagRuleSetsDocument())
      await this._metaRepo.bumpRevision()
    })
  }

  async getTagRuleSetsDocument()
  {
    return (await this._storage.get(IDB_STORE_TAG_RULE_SETS, 'tagRuleSets')) ?? {id: 'tagRuleSets'}
  }

  async putTagRuleSetsDocument(doc)
  {
    doc.id = 'tagRuleSets'
    await this._storage.put(IDB_STORE_TAG_RULE_SETS, doc)
  }

  async getCompiledField(fieldKey)
  {
    let property = TAG_RULESET_PROPERTY_BY_FIELD[fieldKey]
    if (!property) {
      return null
    }
    let doc = await this.getTagRuleSetsDocument()
    return doc[property] ?? null
  }

  /**
   * @param {string} fieldKey
   * @param {string[]} rawLines
   * @param {*} optimized
   * @return {Promise<void>}
   */
  async putCompiledField(fieldKey, rawLines, optimized)
  {
    let property = TAG_RULESET_PROPERTY_BY_FIELD[fieldKey]
    if (!property) {
      return
    }
    let doc = await this.getTagRuleSetsDocument()
    doc[property] = {
      rawLines,
      optimized,
      updatedAt: Date.now(),
    }
    await this.putTagRuleSetsDocument(doc)
  }

  /**
   * @param {string} group
   * @param {Function} optimizeFn
   * @param {boolean} useSelectors
   * @return {Promise<void>}
   */
  async compileGroup(group, optimizeFn, useSelectors = false)
  {
    let fieldKey = Object.entries(TAG_RULE_GROUP_BY_FIELD).find(([, g]) => g === group)?.[0]
    if (!fieldKey) {
      return
    }
    let spec = TAG_FIELD_SPEC[fieldKey]
    if (spec?.usesTaggingRegistry) {
      let rawLines = await this._projectTaggingFieldLines(fieldKey, group, spec)
      let optimized = null
      if (spec.usesCombos) {
        let rules = await this._tagRuleRepo.listByGroup(group)
        let combos = rules.
            filter((rule) => Array.isArray(rule.expression?.tagEntryIds) && rule.expression.tagEntryIds.length >= 2).
            map((rule) => ({tagEntryIds: [...rule.expression.tagEntryIds]}))
        combos.sort((left, right) => left.tagEntryIds.length - right.tagEntryIds.length)
        optimized = {combos}
      }
      await this.putCompiledField(fieldKey, rawLines, optimized)
      await this._metaRepo.clearCompilePending([group])
      return
    }
    let rules = await this._tagRuleRepo.listByGroup(group)
    let rawLines = rules.map((rule) => rule.rawLine).filter(Boolean)
    let optimized = optimizeFn(rawLines, useSelectors)
    await this.putCompiledField(fieldKey, rawLines, optimized)
    await this._metaRepo.clearCompilePending([group])
  }

  /**
   * @param {string} fieldKey
   * @param {string} group
   * @param {*} spec
   * @return {Promise<string[]>}
   * @private
   */
  async _projectTaggingFieldLines(fieldKey, group, spec)
  {
    let lines = []
    let all = await this._storage.getAll(IDB_STORE_TAGS)
    if (spec.substitution) {
      for (let entry of all) {
        normalizeTagEntry(entry)
        if (entry.download?.substitution?.replacementName) {
          lines.push(entry.name + ' → ' + entry.download.substitution.replacementName)
        }
      }
    } else if (spec.soleAttribute) {
      for (let entry of all) {
        normalizeTagEntry(entry)
        if (readTagSoleAttribute(entry, spec.soleAttribute)) {
          lines.push(entry.name)
        }
      }
      lines.sort((left, right) => left.localeCompare(right, undefined, {sensitivity: 'base'}))
    }
    if (spec.usesCombos) {
      let rules = await this._tagRuleRepo.listByGroup(group)
      lines.push(...rules.map((rule) => rule.rawLine).filter(Boolean))
    }
    return lines
  }

  async compilePendingGroups(optimizeFn, useSelectorsByGroup = {})
  {
    let meta = await this._metaRepo.get()
    let groups = meta?.compilePendingGroups ?? []
    for (let group of groups) {
      await this.compileGroup(group, optimizeFn, !!useSelectorsByGroup[group])
    }
  }
}

class TagRuntime
{
  /**
   * @param {TagRepository} tagRepo
   * @param {TagRuleRepository} tagRuleRepo
   */
  constructor(tagRepo, tagRuleRepo)
  {
    this._tagRepo = tagRepo
    this._tagRuleRepo = tagRuleRepo
    /** @type {Map<string, *>} */
    this._byName = new Map()
    /** @type {Map<string, string>} lower(name) → canonical cache key in `_byName` (legacy casing). */
    this._byNameLower = new Map()
    /** @type {Map<number, *>} */
    this._byEntryId = new Map()
    /** @type {Set<string>} Names confirmed absent from IDB (avoid repeat misses). */
    this._missingNames = new Set()
    /** @type {string[]} Insertion order for LRU eviction of `_byName`. */
    this._cacheOrder = []
    this._warmed = false
  }

  clearCache()
  {
    this._byName.clear()
    this._byNameLower.clear()
    this._byEntryId.clear()
    this._missingNames.clear()
    this._cacheOrder = []
    this._warmed = false
  }

  /**
   * @param {*} entry
   * @private
   */
  _cacheEntry(entry)
  {
    if (!entry?.name) {
      return
    }
    normalizeTagEntry(entry)
    delete entry._optimistic
    let name = entry.name
    this._missingNames.delete(name)
    this._missingNames.delete(name.toLowerCase())
    if (!this._byName.has(name)) {
      this._cacheOrder.push(name)
    }
    this._byName.set(name, entry)
    this._byNameLower.set(name.toLowerCase(), name)
    if (entry.entryId != null) {
      this._byEntryId.set(entry.entryId, entry)
    }
    this._evictCacheIfNeeded()
  }

  /**
   * Ensure a sync-readable cache row exists for attribute UI (no IDB I/O).
   * Persisted later via {@link ensureTag} / {@link patchAttributes}.
   *
   * @param {string} name
   * @return {*}
   */
  primeOptimisticEntry(name)
  {
    if (!name) {
      return null
    }
    let existing = this.resolveCachedByName(name)
    if (existing) {
      this._missingNames.delete(name)
      this._missingNames.delete(String(name).toLowerCase())
      return existing
    }
    this._missingNames.delete(name)
    this._missingNames.delete(String(name).toLowerCase())
    let now = Date.now()
    let entry = normalizeTagEntry({
      entryId: null,
      name,
      typeEntryId: null,
      isDiscovered: null,
      compliance: {blacklisted: false, explored: false},
      download: {filenameIgnored: false, substitution: null},
      meta: {createdAt: now, updatedAt: now},
      _optimistic: true,
    })
    this._byName.set(name, entry)
    this._byNameLower.set(String(name).toLowerCase(), name)
    this._cacheOrder.push(name)
    this._evictCacheIfNeeded()
    this._warmed = true
    return entry
  }

  /**
   * @private
   */
  _evictCacheIfNeeded()
  {
    while (this._byName.size > TAG_RUNTIME_CACHE_MAX && this._cacheOrder.length) {
      let evictName = this._cacheOrder.shift()
      let entry = this._byName.get(evictName)
      this._byName.delete(evictName)
      if (evictName) {
        let lower = evictName.toLowerCase()
        if (this._byNameLower.get(lower) === evictName) {
          this._byNameLower.delete(lower)
        }
      }
      if (entry?.entryId != null) {
        this._byEntryId.delete(entry.entryId)
      }
    }
    while (this._missingNames.size > TAG_RUNTIME_CACHE_MAX) {
      let first = this._missingNames.values().next().value
      this._missingNames.delete(first)
    }
  }

  /**
   * Marks the runtime ready for sync readers. Does **not** load the full tags table —
   * use {@link ensureNames} / {@link ensureEntryIds} / {@link ensureComplianceLookups}.
   *
   * @return {Promise<void>}
   */
  async warmCache()
  {
    this._warmed = true
  }

  /**
   * Load the given tag names from IndexedDB into the bounded cache (skips already cached / known-missing).
   *
   * @param {Iterable<string>} names
   * @return {Promise<void>}
   */
  async ensureNames(names)
  {
    let missing = []
    for (let name of names) {
      if (!name || this.resolveCachedByName(name) || this._missingNames.has(name) ||
          this._missingNames.has(String(name).toLowerCase())) {
        continue
      }
      missing.push(name)
    }
    if (!missing.length) {
      this._warmed = true
      return
    }
    let found = await this._tagRepo.getByNames(missing)
    for (let name of missing) {
      let entry = found.get(name)
      if (entry) {
        this._cacheEntry(entry)
      } else {
        this._missingNames.add(name)
        this._missingNames.add(String(name).toLowerCase())
      }
    }
    this._evictCacheIfNeeded()
    this._warmed = true
  }

  /**
   * @param {Iterable<number>} entryIds
   * @return {Promise<void>}
   */
  async ensureEntryIds(entryIds)
  {
    let missing = []
    for (let entryId of entryIds) {
      if (entryId == null || this._byEntryId.has(entryId)) {
        continue
      }
      missing.push(entryId)
    }
    if (!missing.length) {
      this._warmed = true
      return
    }
    let found = await this._tagRepo.getByEntryIds(missing)
    for (let entry of found.values()) {
      this._cacheEntry(entry)
    }
    this._warmed = true
  }

  /**
   * Prefetch tags needed for sync compliance evaluation of the given item tag lists.
   *
   * @param {Iterable<string[]>} itemTagNameLists
   * @param {Iterable<{soleAttribute?: *, combos?: {tagEntryIds: number[]}[]}>} specs
   * @return {Promise<void>}
   */
  async ensureComplianceLookups(itemTagNameLists, specs)
  {
    let names = new Set()
    for (let list of itemTagNameLists) {
      if (!Array.isArray(list)) {
        continue
      }
      for (let name of list) {
        if (name) {
          names.add(name)
        }
      }
    }
    let entryIds = new Set()
    for (let spec of specs) {
      if (!spec) {
        continue
      }
      for (let combo of spec.combos ?? []) {
        for (let entryId of combo.tagEntryIds ?? []) {
          if (entryId != null) {
            entryIds.add(entryId)
          }
        }
      }
    }
    await this.ensureNames(names)
    await this.ensureEntryIds(entryIds)
  }

  /**
   * @param {string} name
   * @return {*|null}
   */
  getCachedByName(name)
  {
    return this.resolveCachedByName(name)
  }

  /**
   * Resolve a cached tag row by exact name or legacy different casing.
   * Same lookup used by ignore chrome and filename join.
   * @param {string} name
   * @return {*|null}
   */
  resolveCachedByName(name)
  {
    if (!name) {
      return null
    }
    if (this._missingNames.has(name)) {
      return null
    }
    let direct = this._byName.get(name)
    if (direct) {
      return direct
    }
    let lower = String(name).toLowerCase()
    if (this._missingNames.has(lower)) {
      return null
    }
    let canonical = this._byNameLower.get(lower)
    if (!canonical) {
      return null
    }
    return this._byName.get(canonical) ?? null
  }

  /**
   * Filename-ignore flag for download join / Aa button (same cached row).
   * @param {string} name
   * @return {boolean}
   */
  isFilenameIgnoredCached(name)
  {
    return !!this.resolveCachedByName(name)?.download?.filenameIgnored
  }

  /**
   * @param {number} entryId
   * @return {*|null}
   */
  getCachedByEntryId(entryId)
  {
    return this._byEntryId.get(entryId) ?? null
  }

  /**
   * @param {string} name
   * @param {{typeEntryId?: number|null, typeName?: string|null, replacementName?: string|null, source?: string}|null} [context]
   * @return {Promise<*>}
   */
  async ensureTag(name, context = null)
  {
    let resolvedTypeId = context?.typeEntryId ?? null
    let needTypeResolve = resolvedTypeId == null && !!context?.typeName
    let seenOnly = context?.source === 'media' || context?.source === 'sidebar'
    // Discovery Confirm / discovery-off resolution — sole writers of isDiscovered.
    let marksDiscovered = context?.source === 'discovery-confirm' || context?.source === 'resolution'
    let cached = this.getCachedByName(name)
    // Optimistic stubs are sync-only — register a real row, then re-apply stub attrs.
    let optimisticSnapshot = null
    if (cached && (cached._optimistic || cached.entryId == null)) {
      optimisticSnapshot = {
        compliance: {
          blacklisted: !!cached.compliance?.blacklisted,
          explored: !!cached.compliance?.explored,
        },
        download: {
          filenameIgnored: !!cached.download?.filenameIgnored,
          substitution: cached.download?.substitution ?? null,
        },
        lastSeenTypeEntryId: cached.meta?.lastSeenTypeEntryId ?? null,
        typeEntryId: cached.typeEntryId ?? null,
        isDiscovered: cached.isDiscovered === true ? true : null,
      }
      cached = null
    }
    if (cached) {
      // Skip type resolve/put when the row already has what this call would write.
      let needsSeenType = seenOnly && needTypeResolve
      let needsConfirmType = !seenOnly && cached.typeEntryId == null && (resolvedTypeId != null || needTypeResolve)
      let needsDiscovered = marksDiscovered && cached.isDiscovered !== true
      if (!needsSeenType && !needsConfirmType && !needsDiscovered && resolvedTypeId == null && !needTypeResolve) {
        return cached
      }
      if (needsSeenType || needsConfirmType || needsDiscovered) {
        if ((needsSeenType || needsConfirmType) && resolvedTypeId == null && context?.typeName) {
          resolvedTypeId = await this._tagRepo.resolveCanonicalTypeEntryId(context.typeName)
        }
        let mutated = false
        if (resolvedTypeId != null && (needsSeenType || needsConfirmType)) {
          if (seenOnly) {
            if (cached.meta.lastSeenTypeEntryId !== resolvedTypeId) {
              cached.meta.lastSeenTypeEntryId = resolvedTypeId
              mutated = true
            }
          } else if (cached.typeEntryId == null) {
            cached.typeEntryId = resolvedTypeId
            mutated = true
          }
        }
        if (needsDiscovered) {
          cached.isDiscovered = true
          mutated = true
        }
        if (mutated) {
          cached = await this._tagRepo.putTag(cached)
          this._cacheEntry(cached)
        }
      }
      return cached
    }
    if (this._missingNames.has(name)) {
      this._missingNames.delete(name)
    }
    if (resolvedTypeId == null && context?.typeName) {
      resolvedTypeId = await this._tagRepo.resolveCanonicalTypeEntryId(context.typeName)
    }
    let typeForRegister = seenOnly ? null : resolvedTypeId
    if (!seenOnly && typeForRegister == null && optimisticSnapshot?.typeEntryId != null) {
      typeForRegister = optimisticSnapshot.typeEntryId
    }
    let entry = await this._tagRepo.registerTag(name, typeForRegister)
    if (resolvedTypeId != null && seenOnly) {
      entry.meta.lastSeenTypeEntryId = resolvedTypeId
      entry = await this._tagRepo.putTag(entry)
    } else if (seenOnly && optimisticSnapshot?.lastSeenTypeEntryId != null &&
        entry.meta.lastSeenTypeEntryId == null) {
      entry.meta.lastSeenTypeEntryId = optimisticSnapshot.lastSeenTypeEntryId
      entry = await this._tagRepo.putTag(entry)
    }
    if (optimisticSnapshot) {
      entry.compliance.blacklisted = optimisticSnapshot.compliance.blacklisted
      entry.compliance.explored = optimisticSnapshot.compliance.explored
      entry.download.filenameIgnored = optimisticSnapshot.download.filenameIgnored
      if (optimisticSnapshot.download.substitution) {
        entry.download.substitution = optimisticSnapshot.download.substitution
      }
    }
    // Attribute / media paths leave isDiscovered null; only Confirm / resolution mark it.
    if (marksDiscovered && entry.isDiscovered !== true) {
      entry.isDiscovered = true
      entry = await this._tagRepo.putTag(entry)
    }
    this._cacheEntry(entry)
    return entry
  }

  /**
   * @param {string[]} names
   * @param {{typeEntryId?: number|null, typeName?: string|null}|null} [context]
   * @return {Promise<Map<string, *>>}
   */
  async ensureTags(names, context = null)
  {
    let map = new Map()
    for (let name of names) {
      if (!name) {
        continue
      }
      map.set(name, await this.ensureTag(name, context))
    }
    return map
  }

  /**
   * @param {string|number} nameOrId
   * @return {Promise<*|null>}
   */
  async getTag(nameOrId)
  {
    if (typeof nameOrId === 'number') {
      let cached = this.getCachedByEntryId(nameOrId)
      if (cached) {
        return cached
      }
      let entry = await this._tagRepo.getByEntryId(nameOrId)
      if (entry) {
        this._cacheEntry(entry)
      }
      return entry
    }
    let cached = this.getCachedByName(nameOrId)
    if (cached) {
      return cached
    }
    if (this._missingNames.has(nameOrId)) {
      return null
    }
    let entry = await this._tagRepo.getByName(nameOrId)
    if (entry) {
      this._cacheEntry(entry)
    } else {
      this._missingNames.add(nameOrId)
      this._evictCacheIfNeeded()
    }
    return entry
  }

  /**
   * @param {string} name
   * @param {*} patch
   * @param {{typeEntryId?: number|null, typeName?: string|null, replacementName?: string|null}|null} [context]
   * @return {Promise<*>}
   */
  async patchAttributes(name, patch, context = null)
  {
    let entry = await this.ensureTag(name, context)
    if (patch.typeEntryId !== undefined) {
      entry.typeEntryId = patch.typeEntryId
    }
    if (patch.compliance) {
      if (patch.compliance.blacklisted !== undefined) {
        entry.compliance.blacklisted = !!patch.compliance.blacklisted
      }
      if (patch.compliance.explored !== undefined) {
        entry.compliance.explored = !!patch.compliance.explored
      }
    }
    if (patch.download) {
      if (patch.download.filenameIgnored !== undefined) {
        entry.download.filenameIgnored = !!patch.download.filenameIgnored
      }
      if (patch.download.substitution !== undefined) {
        if (patch.download.substitution === null) {
          entry.download.substitution = null
        } else {
          let replacementName = patch.download.substitution.replacementName ?? context?.replacementName
          if (!replacementName) {
            throw new Error('Tag substitution requires replacementName context')
          }
          let replacement = await this.ensureTag(replacementName, context)
          entry.download.substitution = {
            replacementName: replacement.name,
            replacementTagEntryId: replacement.entryId,
          }
        }
      }
    }
    entry = await this._tagRepo.putTag(entry)
    this._cacheEntry(entry)
    return entry
  }

  /**
   * @param {{root: string, key: string}} soleAttribute
   * @return {Promise<string[]>}
   */
  async listNamesWithSoleAttribute(soleAttribute)
  {
    let rows = await this._tagRepo._storage.getAll(IDB_STORE_TAGS)
    let names = []
    for (let entry of rows) {
      normalizeTagEntry(entry)
      if (readTagSoleAttribute(entry, soleAttribute)) {
        names.push(entry.name)
      }
    }
    return names.sort((left, right) => left.localeCompare(right, undefined, {sensitivity: 'base'}))
  }

  /**
   * @param {string[]} tagNames
   * @param {boolean} [stripCharacterSeries]
   * @return {string[]}
   */
  applyDownloadAttributesSync(tagNames, stripCharacterSeries = false)
  {
    let result = []
    for (let tagName of tagNames) {
      let entry = this.resolveCachedByName(tagName)
      if (!entry) {
        result.push(tagName)
        continue
      }
      // Same ignore read as Aa / hasTagSoleAttribute (via shared cache row).
      if (entry.download?.filenameIgnored) {
        continue
      }
      let effective = tagName
      let substitution = entry.download?.substitution
      if (substitution?.replacementTagEntryId != null) {
        let replacement = this.getCachedByEntryId(substitution.replacementTagEntryId)
        effective = replacement?.name ?? substitution.replacementName ?? tagName
      } else if (substitution?.replacementName) {
        effective = substitution.replacementName
      }
      if (stripCharacterSeries) {
        let stripped = effective.replace(/_\([^)]*\)$/, '')
        if (stripped !== effective) {
          let strippedEntry = this.resolveCachedByName(stripped)
          if (strippedEntry?.download?.filenameIgnored) {
            continue
          }
          if (strippedEntry?.download?.substitution?.replacementTagEntryId != null) {
            let replacement = this.getCachedByEntryId(strippedEntry.download.substitution.replacementTagEntryId)
            effective = replacement?.name ?? strippedEntry.download.substitution.replacementName ?? stripped
          } else if (strippedEntry?.download?.substitution?.replacementName) {
            effective = strippedEntry.download.substitution.replacementName
          } else {
            effective = stripped
          }
        }
      }
      result.push(effective)
    }
    return result
  }

  /**
   * @param {{tagEntryIds: number[]}} combo
   * @param {Set<number>} postEntryIds
   * @return {boolean}
   */
  evaluateComboExpression(combo, postEntryIds)
  {
    if (!combo?.tagEntryIds?.length) {
      return false
    }
    for (let entryId of combo.tagEntryIds) {
      if (!postEntryIds.has(entryId)) {
        return false
      }
    }
    return true
  }

  /**
   * @param {string[]} itemTagNames
   * @param {{soleAttribute: {root: string, key: string}, combos: {tagEntryIds: number[]}[]}} spec
   * @return {{complies: boolean, rule?: string}}
   */
  evaluateComplianceSync(itemTagNames, spec)
  {
    let postEntryIds = new Set()
    for (let name of itemTagNames) {
      let entry = this.getCachedByName(name)
      if (!entry) {
        continue
      }
      if (entry.entryId != null) {
        postEntryIds.add(entry.entryId)
      }
      // Sole flags apply on optimistic rows too (entryId may still be null).
      if (spec.soleAttribute && readTagSoleAttribute(entry, spec.soleAttribute)) {
        return {complies: false, rule: name}
      }
    }
    for (let combo of spec.combos ?? []) {
      if (this.evaluateComboExpression(combo, postEntryIds)) {
        let labels = combo.tagEntryIds.map((entryId) => this.getCachedByEntryId(entryId)?.name ?? String(entryId))
        return {complies: false, rule: labels.join(' & ')}
      }
    }
    return {complies: true}
  }

  /**
   * @param {string} fieldKey
   * @return {Promise<{soleAttribute: {root: string, key: string}|null, combos: {tagEntryIds: number[]}[]}>}
   */
  async getComplianceSpecForField(fieldKey)
  {
    let spec = TAG_FIELD_SPEC[fieldKey]
    if (!spec) {
      return {soleAttribute: null, combos: []}
    }
    let compiled = await this._tagRepo.getCompiledField(fieldKey)
    let combos = compiled?.optimized?.combos ?? []
    return {
      soleAttribute: spec.soleAttribute ?? null,
      combos,
    }
  }

  /**
   * @param {string} group
   * @param {string} rawLine
   * @param {number} sortOrder
   * @param {boolean} useSelectors
   * @return {Promise<void>}
   */
  async importComboRuleLine(group, rawLine, sortOrder, useSelectors)
  {
    let parts = String(rawLine).split('//')[0].split('&').map((part) => part.trim()).filter(Boolean)
    let tagEntryIds = []
    for (let part of parts) {
      let entry = await this.ensureTag(part)
      tagEntryIds.push(entry.entryId)
    }
    await this._tagRuleRepo.addRule(group, {
      sortOrder,
      rawLine,
      mode: useSelectors ? 'selectors' : 'tags',
      expression: {
        operator: 'and',
        tagEntryIds,
      },
    })
  }

  /**
   * @param {string} fieldKey
   * @param {string[]} lines
   * @return {Promise<void>}
   */
  async persistTaggingFieldLines(fieldKey, lines)
  {
    let spec = TAG_FIELD_SPEC[fieldKey]
    if (!spec) {
      return
    }
    let group = spec.group
    let soleNames = new Set()
    let comboLines = []
    let substitutionLines = []

    for (let rawLine of lines) {
      if (spec.substitution && typeof rawLine === 'object' && rawLine !== null) {
        let subject = String(rawLine.subject ?? '').trim()
        let replacement = String(rawLine.replacement ?? '').trim()
        if (subject && replacement) {
          substitutionLines.push({subject, replacement})
        }
        continue
      }
      let line = String(rawLine).trim()
      if (!line) {
        continue
      }
      if (spec.substitution) {
        let parsed = parseSubstitutionTagLine(line)
        if (parsed) {
          substitutionLines.push(parsed)
          continue
        }
      }
      if (isComboTagLine(line)) {
        comboLines.push(line)
        continue
      }
      let tagName = line.split('//')[0].trim()
      if (tagName && !tagName.includes('→') && !tagName.includes('->')) {
        soleNames.add(tagName)
      }
    }

    if (spec.substitution) {
      let all = await this._tagRepo._storage.getAll(IDB_STORE_TAGS)
      let desiredSubjects = new Set(substitutionLines.map((row) => row.subject))
      for (let entry of all) {
        normalizeTagEntry(entry)
        if (entry.download?.substitution && !desiredSubjects.has(entry.name)) {
          entry.download.substitution = null
          await this._tagRepo.putTag(entry)
          this._cacheEntry(entry)
        }
      }
      for (let row of substitutionLines) {
        await this.patchAttributes(row.subject, {
          download: {
            substitution: {replacementName: row.replacement},
          },
        }, {replacementName: row.replacement})
      }
    } else if (spec.soleAttribute) {
      let all = await this._tagRepo._storage.getAll(IDB_STORE_TAGS)
      for (let entry of all) {
        normalizeTagEntry(entry)
        let hasFlag = readTagSoleAttribute(entry, spec.soleAttribute)
        let shouldHave = soleNames.has(entry.name)
        if (hasFlag && !shouldHave) {
          writeTagSoleAttribute(entry, spec.soleAttribute, false)
          await this._tagRepo.putTag(entry)
          this._cacheEntry(entry)
        }
      }
      for (let tagName of soleNames) {
        let patch = spec.soleAttribute.root === 'compliance' ?
            {compliance: {[spec.soleAttribute.key]: true}} :
            {download: {[spec.soleAttribute.key]: true}}
        await this.patchAttributes(tagName, patch)
      }
    }

    if (spec.usesCombos) {
      let existing = await this._tagRuleRepo.listByGroup(group)
      for (let rule of existing) {
        await this._tagRuleRepo.removeRule(rule.entryId, group)
      }
      let sortOrder = 0
      for (let rawLine of comboLines) {
        await this.importComboRuleLine(group, rawLine, sortOrder++, false)
      }
    }
  }
}

class BrazenStorageRepositories
{
  /**
   * @param {string} scriptPrefix
   * @param {function(string): void|null} [onRepositoryChange]
   * @param {function(number|string): void|null} [onRevisionBump] Called after every local `meta.revisionId` bump so the
   *   script can track revisions it caused (focus sync must ignore those).
   */
  constructor(scriptPrefix, onRepositoryChange = null, onRevisionBump = null)
  {
    this.storage = new BrazenIndexedDBStorage(scriptPrefix)
    this.meta = new MetaRepository(this.storage, onRevisionBump)
    this.settings = new SettingsRepository(this.storage)
    this.bookmarks = new BookmarkRepository(this.storage, this.meta, onRepositoryChange)
    this.ledger = new LedgerRepository(this.storage, this.meta, onRepositoryChange)
    this.downloadResolutionQueue = new DownloadResolutionQueueRepository(this.storage, this.meta, onRepositoryChange)
    this.downloadQueue = new DownloadQueueRepository(this.storage, this.meta, onRepositoryChange)
    this.downloadManagerState = new DownloadManagerStateRepository(this.storage, this.meta, onRepositoryChange)
    this.tagRules = new TagRuleRepository(this.storage, this.meta)
    this.tags = new TagRepository(this.storage, this.meta, this.tagRules)
    this.tagRuntime = new TagRuntime(this.tags, this.tagRules)
  }
}

/**
 * Expand legacy OR rules into separate lines (no | in output).
 * @param {string} line
 * @return {string[]}
 */
function expandOrRuleLine(line)
{
  let commentIndex = line.indexOf('//')
  let rulePart = commentIndex >= 0 ? line.slice(0, commentIndex) : line
  let comment = commentIndex >= 0 ? line.slice(commentIndex) : ''
  let segments = rulePart.split('&')
  let expanded = ['']
  for (let segment of segments) {
    let alternatives = segment.split('|').map((part) => part.trim()).filter(Boolean)
    if (alternatives.length <= 1) {
      expanded = expanded.map((prefix) => prefix ? prefix + '&' + segment.trim() : segment.trim())
      continue
    }
    let next = []
    for (let prefix of expanded) {
      for (let alt of alternatives) {
        next.push(prefix ? prefix + '&' + alt : alt)
      }
    }
    expanded = next
  }
  return expanded.filter(Boolean).map((rule) => rule + comment)
}

/**
 * IEEE / PKZIP CRC-32 over a byte array (polynomial 0xEDB88320).
 * @param {Uint8Array} bytes
 * @return {number}
 */
function zipCrc32(bytes)
{
  let crc = 0xffffffff
  for (let i = 0; i < bytes.length; i++) {
    crc ^= bytes[i]
    for (let bit = 0; bit < 8; bit++) {
      crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1))
    }
  }
  return (crc ^ 0xffffffff) >>> 0
}

/**
 * Minimal ZIP writer (STORE method, no compression) for backup bundles.
 */
class BrazenZipWriter
{
  constructor()
  {
    this._files = []
  }

  /**
   * @param {string} name
   * @param {string} content
   */
  addFile(name, content)
  {
    this._files.push({name, content})
  }

  /**
   * @return {Blob}
   */
  build()
  {
    let parts = []
    let centralDirectory = []
    let offset = 0

    for (let file of this._files) {
      let nameBytes = new TextEncoder().encode(file.name)
      let dataBytes = new TextEncoder().encode(file.content)
      let crc = zipCrc32(dataBytes)
      let header = new DataView(new ArrayBuffer(30))
      header.setUint32(0, 0x04034b50, true)
      header.setUint16(4, 20, true)
      header.setUint16(6, 0, true)
      header.setUint16(8, 0, true)
      header.setUint16(10, 0, true)
      header.setUint16(12, 0, true)
      header.setUint32(14, crc, true)
      header.setUint32(18, dataBytes.length, true)
      header.setUint32(22, dataBytes.length, true)
      header.setUint16(26, nameBytes.length, true)
      header.setUint16(28, 0, true)

      parts.push(new Uint8Array(header.buffer), nameBytes, dataBytes)

      let cdHeader = new DataView(new ArrayBuffer(46))
      cdHeader.setUint32(0, 0x02014b50, true)
      cdHeader.setUint16(4, 20, true)
      cdHeader.setUint16(6, 20, true)
      cdHeader.setUint16(8, 0, true)
      cdHeader.setUint16(10, 0, true)
      cdHeader.setUint16(12, 0, true)
      cdHeader.setUint16(14, 0, true)
      cdHeader.setUint32(16, crc, true)
      cdHeader.setUint32(20, dataBytes.length, true)
      cdHeader.setUint32(24, dataBytes.length, true)
      cdHeader.setUint16(28, nameBytes.length, true)
      cdHeader.setUint16(30, 0, true)
      cdHeader.setUint16(32, 0, true)
      cdHeader.setUint16(34, 0, true)
      cdHeader.setUint16(36, 0, true)
      cdHeader.setUint32(38, 0, true)
      cdHeader.setUint32(42, offset, true)

      centralDirectory.push(new Uint8Array(cdHeader.buffer), nameBytes)
      offset += 30 + nameBytes.length + dataBytes.length
    }

    let centralStart = offset
    for (let part of centralDirectory) {
      parts.push(part)
      offset += part.length
    }

    let end = new DataView(new ArrayBuffer(22))
    end.setUint32(0, 0x06054b50, true)
    end.setUint16(4, 0, true)
    end.setUint16(6, 0, true)
    end.setUint16(8, this._files.length, true)
    end.setUint16(10, this._files.length, true)
    end.setUint32(12, centralDirectory.reduce((sum, part) => sum + part.length, 0), true)
    end.setUint32(16, centralStart, true)
    end.setUint16(20, 0, true)
    parts.push(new Uint8Array(end.buffer))

    return new Blob(parts, {type: 'application/zip'})
  }
}

/**
 * Minimal ZIP reader (STORE method only) for backup restore.
 */
class BrazenZipReader
{
  /**
   * @param {ArrayBuffer} buffer
   * @return {Record<string, string>}
   */
  static parse(buffer)
  {
    let view = new DataView(buffer)
    let files = {}
    let offset = 0

    while (offset + 30 <= buffer.byteLength) {
      let signature = view.getUint32(offset, true)
      if (signature === 0x04034b50) {
        let compression = view.getUint16(offset + 8, true)
        let compressedSize = view.getUint32(offset + 18, true)
        let nameLength = view.getUint16(offset + 26, true)
        let extraLength = view.getUint16(offset + 28, true)
        let nameStart = offset + 30
        let name = new TextDecoder().decode(new Uint8Array(buffer, nameStart, nameLength))
        let dataStart = nameStart + nameLength + extraLength
        if (compression !== 0) {
          throw new Error('Compressed zip entries are not supported: ' + name)
        }
        let dataBytes = new Uint8Array(buffer, dataStart, compressedSize)
        files[name] = new TextDecoder().decode(dataBytes)
        offset = dataStart + compressedSize
        continue
      }
      if (signature === 0x02014b50 || signature === 0x06054b50) {
        break
      }
      break
    }

    return files
  }
}

class BrazenLegacyImporter
{
  /**
   * @param {BrazenStorageRepositories} repos
   * @param {BrazenConfigurationManager} manager
   */
  constructor(repos, manager)
  {
    this._repos = repos
    this._manager = manager
    this._summary = {}
  }

  /**
   * @return {Promise<string[]>}
   */
  async run()
  {
    let sources = []
    let prefix = this._manager._scriptPrefix
    let legacyPrefix = this._manager._legacyScriptPrefix
    this._errors = []

    await this._importSettingsRevision(prefix, legacyPrefix, sources)
    await this._importSettings(prefix, legacyPrefix, sources)
    await this._importBookmarks(prefix, legacyPrefix, sources)
    await this._verifyAndPurgeLegacy(prefix, legacyPrefix, sources)

    console.log('[BrazenIDB] legacy import summary:', this._summary, 'sources:', sources, 'errors:', this._errors)
    if (this._summary.importedTotal > 0) {
      alert('Brazen script — settings migrated to IndexedDB (' + this._summary.importedTotal + ' records).')
    }
    return sources
  }

  async _importSettingsRevision(prefix, legacyPrefix, sources)
  {
    for (let backend of LEGACY_STORAGE_BACKENDS) {
      for (let activePrefix of [prefix, legacyPrefix]) {
        if (!activePrefix) {
          continue
        }
        let revision = backend.read(activePrefix + 'settings-id', null)
        if (typeof revision === 'number' || (typeof revision === 'string' && revision.trim())) {
          let meta = await this._repos.meta.get() ?? await this._repos.storage.createDefaultMeta()
          meta.revisionId = String(revision)
          await this._repos.meta.put(meta)
          if (!sources.includes(backend.source)) {
            sources.push(backend.source)
          }
          this._summary.settingsRevision = meta.revisionId
          return
        }
      }
    }
  }

  async _verifyAndPurgeLegacy(prefix, legacyPrefix, sources)
  {
    let settingsDoc = await this._repos.settings.getDocument()
    let bookmarkCount = await this._repos.storage.count(IDB_STORE_BOOKMARKS)
    let tagCount = await this._repos.storage.count(IDB_STORE_TAGS)
    let tagRuleCount = await this._repos.storage.count(IDB_STORE_TAG_RULES)

    let expectedSettings = this._summary.settings ?? 0
    if (expectedSettings && Object.keys(settingsDoc).length <= 1) {
      throw new Error('Legacy settings import verification failed')
    }
    if ((this._summary.bookmarksExpected ?? 0) > 0 && bookmarkCount === 0) {
      throw new Error('Legacy bookmark import verification failed')
    }

    this._summary.importedTotal = (this._summary.settings ?? 0) + bookmarkCount + tagCount + tagRuleCount
    this._summary.migrationSources = [...sources]

    for (let backend of LEGACY_STORAGE_BACKENDS) {
      this._safeRemove(backend, prefix + 'settings')
      this._safeRemove(backend, prefix + 'settings-id')
      this._safeRemove(backend, prefix + 'bookmarks')
      if (legacyPrefix && legacyPrefix !== prefix) {
        this._safeRemove(backend, legacyPrefix + 'settings')
        this._safeRemove(backend, legacyPrefix + 'settings-id')
        this._safeRemove(backend, legacyPrefix + 'bookmarks')
      }
    }
  }

  _safeRemove(backend, key)
  {
    if (!key) {
      return
    }
    try {
      if (backend.read(key, null) !== null) {
        backend.remove(key)
      }
    } catch (error) {
      console.log('[BrazenIDB] failed to purge legacy key:', key, error)
    }
  }

  async _importSettings(prefix, legacyPrefix, sources)
  {
    let aggregate = null
    for (let backend of LEGACY_STORAGE_BACKENDS) {
      let current = this._coerceSettingsBlob(backend.read(prefix + 'settings', null))
      if (current.settings) {
        aggregate = {...(aggregate ?? {}), ...current.settings}
        if (!sources.includes(backend.source)) {
          sources.push(backend.source)
        }
      } else if (current.invalid) {
        this._errors.push('settings-invalid:' + backend.source)
      }
      if (legacyPrefix && legacyPrefix !== prefix) {
        let legacy = this._coerceSettingsBlob(backend.read(legacyPrefix + 'settings', null))
        if (legacy.settings) {
          aggregate = {...legacy.settings, ...(aggregate ?? {})}
          if (!sources.includes(backend.source)) {
            sources.push(backend.source)
          }
        } else if (legacy.invalid) {
          this._errors.push('legacy-settings-invalid:' + backend.source)
        }
      }
    }
    if (!aggregate) {
      this._summary.settings = 0
      return
    }

    let doc = await this._repos.settings.getDocument()
    let count = 0
    for (let fieldKey in aggregate) {
      if (TAG_DOMAIN_FIELD_KEYS.has(fieldKey)) {
        continue
      }
      let property = fieldKeyToProperty(fieldKey)
      let value = aggregate[fieldKey]
      doc[property] = {
        value,
        optimized: this._manager._computeFieldOptimized(fieldKey, value),
        updatedAt: Date.now(),
      }
      count++
    }
    await this._repos.settings.putDocument(doc)
    this._summary.settings = count
  }

  /**
   * Normalize a driver settings blob. Empty objects, arrays, and whitespace strings are treated as absent (not errors).
   * @param {*} blob
   * @return {{settings: Object|null, invalid: boolean}}
   * @private
   */
  _coerceSettingsBlob(blob)
  {
    if (blob === null || blob === undefined) {
      return {settings: null, invalid: false}
    }
    blob = Utilities.reviveGmStoredValue(blob)
    if (typeof blob === 'string') {
      let trimmed = blob.trim()
      if (!trimmed.length) {
        return {settings: null, invalid: false}
      }
      try {
        blob = JSON.parse(trimmed)
      } catch (error) {
        return {settings: null, invalid: true}
      }
    }
    if (blob && typeof blob === 'object' && 'arrays' in blob && 'objects' in blob && 'properties' in blob) {
      try {
        blob = Utilities.objectFromJSON(JSON.stringify(blob))
      } catch (error) {
        return {settings: null, invalid: true}
      }
    }
    if (!blob || typeof blob !== 'object' || Array.isArray(blob) || !Object.keys(blob).length) {
      return {settings: null, invalid: false}
    }
    return {settings: blob, invalid: false}
  }

  async _importBookmarks(prefix, legacyPrefix, sources)
  {
    let rows = []
    let expected = 0
    let keys = [prefix + 'bookmarks']
    if (legacyPrefix) {
      keys.push(legacyPrefix + 'bookmarks')
    }
    for (let key of keys) {
      let stored = readLegacyGm(key, null)
      let list = Utilities.coerceBookmarkArray(stored)
      expected += list.length
      for (let bookmark of list) {
        let normalized = this._normalizeBookmarkRow(bookmark)
        if (normalized) {
          rows.push(normalized)
        }
      }
      if (list.length && !sources.includes(LEGACY_SOURCE_GM)) {
        sources.push(LEGACY_SOURCE_GM)
      }
    }

    for (let backend of LEGACY_STORAGE_BACKENDS) {
      for (let activePrefix of [prefix, legacyPrefix]) {
        if (!activePrefix) {
          continue
        }
        let settings = backend.read(activePrefix + 'settings', null)
        let fromSettings = Utilities.coerceBookmarkArray(settings?.bookmarks)
        expected += fromSettings.length
        for (let bookmark of fromSettings) {
          let normalized = this._normalizeBookmarkRow(bookmark)
          if (normalized) {
            rows.push(normalized)
          }
        }
        if (fromSettings.length && !sources.includes(backend.source)) {
          sources.push(backend.source)
        }
      }
    }

    if (rows.length) {
      await this._repos.bookmarks.replaceAll(rows)
    }
    this._summary.bookmarks = rows.length
    this._summary.bookmarksExpected = expected
  }

  _normalizeBookmarkRow(bookmark)
  {
    if (!bookmark || typeof bookmark !== 'object') {
      return null
    }
    let tags = typeof bookmark.tags === 'string' ? bookmark.tags.trim() : ''
    if (!tags.length) {
      return null
    }
    return this._bookmarkToRow(bookmark)
  }

  _bookmarkToRow(bookmark)
  {
    return {
      entryId: null,
      label: bookmark.label ?? '',
      tags: bookmark.tags ?? '',
      url: bookmark.url ?? '',
      sortOrder: 0,
      createdAt: Date.now(),
      updatedAt: Date.now(),
    }
  }

}