import BaseAdapter , { StorageProxy } from "./base" ; import { filterObject , omitKeys , sortObjects , arrayEqual , transformSubObjectFilters , } from "../utils" ; import { RecordStatus } from "../types" ; import { KintoObject } from "kinto-http" ; const INDEXED_FIELDS = [ "id" , "_status" , "last_modified" ] ; param dbname { String } param version { Integer } param onupgradeneeded { Function } return { Promise < IDBDatabase > } export async function open ( dbname , { version , onupgradeneeded , } ) { return new Promise ( ( resolve , reject ) => { const request = indexedDB . open ( dbname , version ) ; request . onupgradeneeded = ( event ) => { const db = request . result ; db . onerror = ( event ) => reject ( request . error ) ; const transaction = request . transaction ! ; transaction . onabort = ( event ) => { const error = request . error || transaction . error || new DOMException ( "The operation has been aborted" , "AbortError" ) ; reject ( error ) ; } ; return onupgradeneeded ( event ) ; } ; request . onerror = ( event ) => { reject ( ( event . target as IDBRequest ) . error ) ; } ; request . onsuccess = ( event ) => { const db = request . result ; resolve ( db ) ; } ; } ) ; } param db { IDBDatabase } param name { String } param callback { Function } param options param options . mode { String } return { Promise } export async function execute ( db , name , callback , options = { } ) { const { mode } = options ; return new Promise ( ( resolve , reject ) => { const transaction = mode ? db . transaction ( [ name ] , mode ) : db . transaction ( [ name ] ) ; const store = transaction . objectStore ( name ) ; const abort = ( e ) => { transaction . abort ( ) ; console . error ( e ) ; reject ( e ) ; } ; let result ; try { result = callback ( store , abort ) ; } catch ( e ) { abort ( e ) ; } transaction . onerror = ( event ) => reject ( ( event . target as IDBTransaction ) . error ) ; transaction . oncomplete = ( event ) => resolve ( result ) ; transaction . onabort = ( event ) => { const error = ( event . target as IDBTransaction ) . error || transaction . error || new DOMException ( "The operation has been aborted" , "AbortError" ) ; reject ( error ) ; } ; } ) ; } param dbName { String } return { Promise } async function deleteDatabase ( dbName ) { return new Promise ( ( resolve , reject ) => { const request = indexedDB . deleteDatabase ( dbName ) ; request . onsuccess = ( event ) => resolve ( event . target as IDBOpenDBRequest ) ; request . onerror = ( event ) => reject ( ( event . target as IDBOpenDBRequest ) . error ) ; } ) ; } type { Object } const cursorHandlers = { all ( filters , done ) { const results = [ ] ; return ( event ) => { const cursor = ( event . target as IDBRequest < IDBCursorWithValue > ) . result ; if ( cursor ) { const { value } = cursor ; if ( filterObject ( filters , value ) ) { results . push ( value ) ; } cursor . continue ( ) ; } else { done ( results ) ; } } ; } , in ( values , filters , done ) { const results = [ ] ; let i = 0 ; return function ( event ) { const cursor = ( event . target as IDBRequest < IDBCursorWithValue > ) . result ; if ( ! cursor ) { done ( results ) ; return ; } const { key , value } = cursor ; while ( key > values [ i ] ) { ++ i ; if ( i === values . length ) { done ( results ) ; return ; } } const isEqual = Array . isArray ( key ) ? arrayEqual ( key , values [ i ] ) : key === values [ i ] ; if ( isEqual ) { if ( filterObject ( filters , value ) ) { results . push ( value ) ; } cursor . continue ( ) ; } else { cursor . continue ( values [ i ] ) ; } } ; } , } ; param { String } cid param { IDBStore } store param { Object } filters param { Function } done return { IDBRequest } function createListRequest ( cid , store , filters , done ) { const filterFields = Object . keys ( filters ) ; if ( filterFields . length === 0 ) { const request = store . index ( "cid" ) . getAll ( IDBKeyRange . only ( cid ) ) ; request . onsuccess = ( event ) => done ( ( event . target as IDBRequest ) . result ) ; return request ; } const indexField = filterFields . find ( ( field ) => { return INDEXED_FIELDS . includes ( field ) ; } ) ; if ( ! indexField ) { const isSubQuery = Object . keys ( filters ) . some ( ( key ) => key . includes ( "." ) ) ; if ( isSubQuery ) { const newFilter = transformSubObjectFilters ( filters ) ; const request = store . index ( "cid" ) . openCursor ( IDBKeyRange . only ( cid ) ) ; request . onsuccess = cursorHandlers . all ( newFilter , done ) ; return request ; } const request = store . index ( "cid" ) . openCursor ( IDBKeyRange . only ( cid ) ) ; request . onsuccess = cursorHandlers . all ( filters , done ) ; return request ; } const remainingFilters = omitKeys ( filters , [ indexField ] ) ; const value = filters [ indexField ] ; const indexStore = indexField === "id" ? store : store . index ( indexField ) ; if ( Array . isArray ( value ) ) { if ( value . length === 0 ) { return done ( [ ] ) ; } const values = value . map ( ( i ) => [ cid , i ] ) . sort ( ) ; const range = IDBKeyRange . bound ( values [ 0 ] , values [ values . length - 1 ] ) ; const request = indexStore . openCursor ( range ) ; request . onsuccess = cursorHandlers . in ( values , remainingFilters , done ) ; return request ; } if ( Object . keys ( remainingFilters ) . length === 0 ) { const request = indexStore . getAll ( IDBKeyRange . only ( [ cid , value ] ) ) ; request . onsuccess = ( event ) => done ( ( event . target as IDBRequest ) . result ) ; return request ; } const request = indexStore . openCursor ( IDBKeyRange . only ( [ cid , value ] ) ) ; request . onsuccess = cursorHandlers . all ( remainingFilters , done ) ; return request ; } class IDBError extends Error { constructor ( method , err ) { super ( `IndexedDB ${ method }() ${ err . message }` ) ; this . name = err . name ; this . stack = err . stack ; } } /**
 * IndexedDB adapter.
 *
 * This adapter doesn't support any options.
 */ export default class IDB < B extends { id : string ; last_modified ? : number ; _status ? : RecordStatus ; } > extends BaseAdapter < B > { private _db : IDBDatabase | null ; public cid : string ; public dbName : string ; private _options : { dbName ? : string ; migrateOldData ? : boolean ; } ; static get IDBError ( ) { return IDBError ; } param { String } cid param options param { String } options . dbName param { String } options . migrateOldData constructor ( cid , options = { } ) { super ( ) ; this . cid = cid ; this . dbName = options . dbName || "KintoDB" ; this . _options = options ; this . _db = null ; } _handleError ( method , err ) { throw new IDBError ( method , err ) ; } @override
     *  return { Promise } async open ( ) { if ( this . _db ) { return this ; } const dataToMigrate = this . _options . migrateOldData ? await migrationRequired ( this . cid ) : null ; this . _db = await open ( this . dbName , { version : 2 , onupgradeneeded : ( event ) => { const db = ( event . target as IDBRequest < IDBDatabase > ) . result ; if ( event . oldVersion < 1 ) { const recordsStore = db . createObjectStore ( "records" , { keyPath : [ "_cid" , "id" ] , } ) ; recordsStore . createIndex ( "cid" , "_cid" ) ; recordsStore . createIndex ( "_status" , [ "_cid" , "_status" ] ) ; recordsStore . createIndex ( "last_modified" , [ "_cid" , "last_modified" ] ) ; db . createObjectStore ( "timestamps" , { keyPath : "cid" , } ) ; } if ( event . oldVersion < 2 ) { db . createObjectStore ( "collections" , { keyPath : "cid" , } ) ; } } , } ) ; if ( dataToMigrate ) { const { records , timestamp } = dataToMigrate ; await this . importBulk ( records as ( B & { last_modified : number ; } ) [ ] ) ; await this . saveLastModified ( timestamp ?? 0 ) ; console . log ( `${ this . cid }: data was migrated successfully.` ) ; await deleteDatabase ( this . cid ) ; console . warn ( `${ this . cid }: old database was deleted.` ) ; } return this ; } @override
     *  return { Promise } close ( ) { if ( this . _db ) { this . _db . close ( ) ; this . _db = null ; } return Promise . resolve ( ) ; } param { String } name param { Function } callback param options param { String } options . mode return { Object } async prepare ( name , callback , options ? ) { await this . open ( ) ; await execute ( this . _db ! , name , callback , options ) ; } @override
     *  return { Promise } async clear ( ) { try { await this . prepare ( "records" , ( store ) => { const range = IDBKeyRange . only ( this . cid ) ; const request = store . index ( "cid" ) . openKeyCursor ( range ) ; request . onsuccess = ( event ) => { const cursor = ( event . target as IDBRequest < IDBCursor > ) . result ; if ( cursor ) { store . delete ( cursor . primaryKey ) ; cursor . continue ( ) ; } } ; return request ; } , { mode : "readwrite" } ) ; } catch ( e ) { this . _handleError ( "clear" , e ) ; } } example @override
     *  param { Function } callback param { Object } options return { Promise } async execute < T > ( callback , options = { preload : [ ] } ) { let result ; await this . prepare ( "records" , ( store , abort ) => { const runCallback = ( preloaded = { } ) => { const proxy = transactionProxy ( this , store , preloaded ) ; try { const returned = callback ( proxy ) ; if ( returned instanceof Promise ) { throw new Error ( "execute() callback should not return a Promise." ) ; } result = returned ; } catch ( e ) { abort && abort ( e ) ; } } ; if ( ! options . preload ) { return runCallback ( ) ; } const filters = { id : options . preload } ; createListRequest ( this . cid , store , filters , ( records ) => { const preloaded = { } ; for ( const record of records ) { delete record [ "_cid" ] ; preloaded [ record . id ] = record ; } runCallback ( preloaded ) ; } ) ; } , { mode : "readwrite" } ) ; return result ! ; } @override
     *  param { String } id return { Promise } async get ( id ) { try { let record ; await this . prepare ( "records" , ( store ) => { store . get ( [ this . cid , id ] ) . onsuccess = ( e ) => ( record = ( e . target as IDBRequest < KintoObject > ) . result as B ) ; } ) ; return record ! ; } catch ( e ) { this . _handleError ( "get" , e ) ; } } @override
     *  param { Object } params return { Promise } async list ( params = { filters : { } , } ) { const { filters } = params ; try { let results = [ ] ; await this . prepare ( "records" , ( store ) => { createListRequest ( this . cid , store , filters , ( _results ) => { for ( const result of _results ) { delete result [ "_cid" ] ; } results = _results ; } ) ; } ) ; return params . order ? sortObjects ( params . order , results ) : results ; } catch ( e ) { this . _handleError ( "list" , e ) ; } return [ ] ; } @override
     *  param { Number } lastModified return { Promise } async saveLastModified ( lastModified ) { const value = lastModified || null ; try { await this . prepare ( "timestamps" , ( store ) => { if ( value === null ) { store . delete ( this . cid ) ; } else { store . put ( { cid : this . cid , value } ) ; } } , { mode : "readwrite" } ) ; return value ; } catch ( e ) { this . _handleError ( "saveLastModified" , e ) ; } return null ; } @override
     *  return { Promise } async getLastModified ( ) { try { let entry = null as { value : number ; } | null ; await this . prepare ( "timestamps" , ( store ) => { store . get ( this . cid ) . onsuccess = ( e ) => { entry = ( e . target as IDBRequest < { value : number ; } > ) . result ; } ; } ) ; return entry ? entry . value : null ; } catch ( e ) { this . _handleError ( "getLastModified" , e ) ; } return null ; } deprecated Use  importBulk instead.
     *  abstract param { Array } records return { Promise } async loadDump ( records ) { return this . importBulk ( records ) ; } abstract param { Array } records return { Promise } async importBulk ( records ) { try { await this . execute ( ( transaction ) => { let i = 0 ; putNext ( ) ; function putNext ( ) { if ( i === records . length ) { return ; } transaction . update ( records [ i ] ) . onsuccess = putNext ; ++ i ; } } ) ; const previousLastModified = await this . getLastModified ( ) ; const lastModified = Math . max ( ... records . map ( ( record ) => record . last_modified ) ) ; if ( previousLastModified && lastModified > previousLastModified ) { await this . saveLastModified ( lastModified ) ; } return records ; } catch ( e ) { this . _handleError ( "importBulk" , e ) ; } return [ ] ; } async saveMetadata ( metadata ) { try { await this . prepare ( "collections" , ( store ) => store . put ( { cid : this . cid , metadata } ) , { mode : "readwrite" } ) ; return metadata ; } catch ( e ) { this . _handleError ( "saveMetadata" , e ) ; } } async getMetadata ( ) { try { let entry = null ; await this . prepare ( "collections" , ( store ) => { store . get ( this . cid ) . onsuccess = ( e ) => ( entry = ( e . target as IDBRequest < { metadata : any ; } > ) . result ) ; } ) ; return entry ? ( entry as { metadata : any ; } ) . metadata : null ; } catch ( e ) { this . _handleError ( "getMetadata" , e ) ; } } } param { IDB } adapter param { IDBStore } store param { Array } preloaded return { Object } function transactionProxy < T extends { id : string ; last_modified ? : number ; _status ? : RecordStatus ; } > ( adapter , store , preloaded = { } ) { const _cid = adapter . cid ; return { create ( record ) { store . add ( { ... record , _cid } ) ; } , update ( record ) { return store . put ( { ... record , _cid } ) ; } , delete ( id ) { store . delete ( [ _cid , id ] ) ; } , get ( id ) { return preloaded [ id ] ; } , } ; } /**
 * Up to version 10.X of kinto.js, each collection had its own collection.
 * The database name was `${bid}/${cid}` (eg. `"blocklists/certificates"`)
 * and contained only one store with the same name.
 */ async function migrationRequired < T extends { id : string ; last_modified ? : number ; _status ? : RecordStatus ; } > ( dbName ) { let exists = true ; const db = await open ( dbName , { version : 1 , onupgradeneeded : ( event ) => { exists = false ; } , } ) ; exists = db . objectStoreNames . contains ( "__meta__" ) && db . objectStoreNames . contains ( dbName ) ; if ( ! exists ) { db . close ( ) ; await deleteDatabase ( dbName ) ; return null ; } console . warn ( `${ dbName }: old IndexedDB database found.` ) ; try { let records ; await execute ( db , dbName , ( store ) => { store . openCursor ( ) . onsuccess = cursorHandlers . all ( { } , ( res ) => ( records = res as T [ ] ) ) ; } ) ; console . log ( `${ dbName }: found ${ records ! . length } records.` ) ; let timestamp = null ; await execute ( db , "__meta__" , ( store ) => { store . get ( `${ dbName }-lastModified` ) . onsuccess = ( e ) => { timestamp = ( e . target as IDBRequest ) . result ? ( e . target as IDBRequest < { value : number ; } > ) . result . value : null ; } ; } ) ; if ( ! timestamp ) { await execute ( db , "__meta__" , ( store ) => { store . get ( "lastModified" ) . onsuccess = ( e ) => { timestamp = ( e . target as IDBRequest ) . result ? ( e . target as IDBRequest < { value : number ; } > ) . result . value : null ; } ; } ) ; } console . log ( `${ dbName }: ${ timestamp ? "found" : "no" } timestamp.` ) ; return { records : records ! , timestamp } ; } catch ( e ) { console . error ( "Error occured during migration" , e ) ; return null ; } finally { db . close ( ) ; } }