From 862d8a80afb995215aa4c1e6141db40e2cd07c8d Mon Sep 17 00:00:00 2001 From: Ewout Stortenbeker Date: Mon, 17 Oct 2022 15:58:09 +0200 Subject: [PATCH] ported all source to typescript --- src/ts/acebase-client.ts | 303 +++-- src/ts/api-web.ts | 2397 ++++++++++++++++++--------------- src/ts/auth.ts | 296 ++-- src/ts/base64/browser.ts | 16 +- src/ts/base64/index.ts | 16 +- src/ts/browser.ts | 9 +- src/ts/errors.ts | 8 +- src/ts/index.ts | 24 +- src/ts/performance/browser.ts | 2 +- src/ts/performance/index.ts | 4 +- src/ts/promise-timeout.ts | 12 +- src/ts/request/browser.ts | 68 +- src/ts/request/error.ts | 11 +- src/ts/request/index.ts | 50 +- src/ts/server-date.ts | 14 +- src/ts/user.ts | 131 +- 16 files changed, 1842 insertions(+), 1519 deletions(-) diff --git a/src/ts/acebase-client.ts b/src/ts/acebase-client.ts index 05538af..cd30045 100644 --- a/src/ts/acebase-client.ts +++ b/src/ts/acebase-client.ts @@ -1,88 +1,190 @@ -const { AceBaseBase, DebugLogger, ColorStyle, DataSnapshot } = require('acebase-core'); -const { WebApi } = require('./api-web'); -const { AceBaseClientAuth } = require('./auth'); -const { setServerBias } = require('./server-date'); +import { AceBaseBase, LoggingLevel, DebugLogger, ColorStyle, DataSnapshot } from 'acebase-core'; +import { WebApi } from './api-web'; +import { AceBaseClientAuth } from './auth'; +import { setServerBias } from './server-date'; -class AceBaseClientConnectionSettings { +/** + * Settings to connect to a remote AceBase server + */ +export class AceBaseClientConnectionSettings { /** - * Settings to connect to a remote AceBase server - * @param {object} settings - * @param {string} settings.dbname Name of the database you want to access - * @param {string} settings.host Host name, eg "localhost", or "mydb.domain.com" - * @param {number} settings.port Port number the server is running on - * @param {boolean} [settings.https=true] Use SSL (https) to access the server or not. Default: true - * @param {boolean} [settings.autoConnect=true] Automatically connect to the server, or wait until .connect is called - * @param {number} [settings.autoConnectDelay=0] Delay before auto connection. Useful for testing scenarios where both server and client start at the same time, and server needs to come online first. - * @param {object} [settings.cache] Settings for local cache - * @param {AceBase} [settings.cache.db] AceBase database instance to use for local cache - * @param {'verbose'|'log'|'warn'|'error'} [settings.logLevel='log'] debug logging level - * @param {object} [settings.sync] Settings for synchronization - * @param {'connect'|'signin'|'auto'|'manual'} [settings.sync.timing] Determines when synchronization should execute - * @param {boolean} [settings.sync.useCursor] Whether to enable cursor synchronization if transaction logging is enabled in the server configuration - * @param {object} [settings.network] Network settings - * @param {boolean} [settings.network.monitor=false] Whether to actively monitor the network for availability by pinging the server every `interval` seconds. This results in quicker offline detection. Default is `false` if `realtime` is `true` and vice versa - * @param {number} [settings.network.interval=60] Interval in seconds to send pings if `monitor` is `true`. Default is `60` - * @param {string[]} [settings.network.transports] Transport methods to try connecting to the server for realtime event notifications (in specified order). Default is `['websocket']`. Supported transport methods are `"websocket"` and `"polling"`. - * @param {boolean} [settings.network.realtime=true] Whether to connect to a serverwebsocket to enable realtime event notifications. Default is `true`. Disable this option if you only want to use the server's REST API. - * @param {boolean} [settings.sponsor=false] You can turn this on if you are a sponsor. See https://github.com/appy-one/acebase/discussions/100 for more info + * Name of the database you want to access */ - constructor(settings) { + dbname: string; + + /** + * Host name, eg "localhost", or "mydb.domain.com" + */ + host: string; + + /** + * Port number the server is running on + */ + port: number; + + /** + * Use SSL (https) to access the server or not. Default: `true` + * @default true + */ + https = true; + + /** + * Automatically connect to the server, or wait until `db.connect()` is called + * @default true + */ + autoConnect = true; + + /** + * Delay in ms before auto connecting. Useful for testing scenarios where both server and client start at the same time, and server needs to come online first. + * @default 0 + */ + autoConnectDelay = 0; + + /** + * Settings for local cache + */ + cache: { + /** + * AceBase database instance to use for local cache + */ + db: AceBaseBase | null; + enabled: boolean; + priority: 'cache' | 'server'; + }; + + /** + * debug logging level + */ + logLevel: LoggingLevel = 'log'; + + /** + * Settings for synchronization + */ + sync: { + /** + * Determines when synchronization should execute + * @default 'auto' + */ + timing: 'connect' | 'signin' | 'auto' | 'manual'; + + /** + * Whether to enable cursor synchronization if transaction logging is enabled in the server configuration + * @default true + */ + useCursor: boolean; + }; + + /** + * Network settings + */ + network: { + /** + * Whether to actively monitor the network for availability by pinging the server every `interval` seconds. + * This results in quicker offline detection. Default is `false` if `realtime` is `true` and vice versa + */ + monitor: boolean; + + /** + * Interval in seconds to send pings if `monitor` is `true`. Default is `60` + * @default 60 + */ + interval: number; + + /** + * Transport methods to try connecting to the server for realtime event notifications (in specified order). + * Default is `['websocket']`. Supported transport methods are `"websocket"` and `"polling"`. + * @default ['websocket'] + */ + transports: Array<'websocket'|'polling'>; + + /** + * Whether to connect to a serverwebsocket to enable realtime event notifications. Default is `true`. + * Disable this option if you only want to use the server's REST API. + * @default true + */ + realtime: boolean; + }; + + /** + * You can turn this on if you are a sponsor. See https://github.com/appy-one/acebase/discussions/100 for more info + */ + sponsor: boolean; + + constructor( + settings: ConnectionSettingsInit, + ) { this.dbname = settings.dbname; this.host = settings.host; this.port = settings.port; this.https = typeof settings.https === 'boolean' ? settings.https : true; this.autoConnect = typeof settings.autoConnect === 'boolean' ? settings.autoConnect : true; this.autoConnectDelay = typeof settings.autoConnectDelay === 'number' ? settings.autoConnectDelay : 0; - this.cache = typeof settings.cache === 'object' && typeof settings.cache.db === 'object' ? settings.cache : null; // && settings.cache.db.constructor.name.startsWith('AceBase') this.logLevel = typeof settings.logLevel === 'string' ? settings.logLevel : 'log'; this.sponsor = typeof settings.sponsor === 'boolean' ? settings.sponsor : false; - + + // Set cache settings + this.cache = { + enabled: typeof settings.cache?.db === 'object' && typeof settings.cache?.enabled === 'boolean' ? settings.cache.enabled : false, + db: typeof settings.cache?.db === 'object' ? settings.cache.db : null, + priority: typeof settings.cache?.priority === 'string' && ['server','cache'].includes(settings.cache.priority) ? settings.cache.priority : 'server', + }; + // Set sync settings - this.sync = settings.sync; - if (this.sync === null || typeof this.sync !== 'object') { this.sync = {}; } - if (!['connect','signin','auto','manual'].includes(this.sync.timing)) { this.sync.timing = 'auto'; } - if (typeof this.sync.useCursor !== 'boolean') { this.sync.useCursor = true; } - + this.sync = { + timing: typeof settings.sync?.timing === 'string' && ['connect','signin','auto','manual'].includes(settings.sync.timing) ? settings.sync.timing : 'auto', + useCursor: typeof settings.sync?.useCursor === 'boolean' ? settings.sync.useCursor : true, + }; + // Set network settings - this.network = settings.network; - if (this.network === null || typeof this.network !== 'object') { this.network = {}; } - if (!(this.network.transports instanceof Array)) { this.network.transports = ['websocket'] } - if (typeof this.network.realtime !== 'boolean') { this.network.realtime = true; } - if (typeof this.network.monitor !== 'boolean') { this.network.monitor = !this.network.realtime; } - if (typeof this.network.interval !== 'number') { this.network.interval = 60; } + const realtime = typeof settings.network?.realtime === 'boolean' ? settings.network.realtime : true; + this.network = { + transports: settings.network?.transports instanceof Array ? settings.network.transports : ['websocket'], + realtime, + monitor: typeof settings.network?.monitor === 'boolean' ? settings.network.monitor : !realtime, + interval: typeof settings.network?.interval === 'number' ? settings.network.interval : 60, + }; } } +export type ConnectionSettingsInit = Omit, 'dbname' | 'host' | 'port' | 'sync' | 'network' | 'cache'> +& Pick +& { + sync?: Partial; + network?: Partial; + cache?: Partial; +}; + /** * AceBaseClient lets you connect to a remote (or local) AceBase server over http(s) - * @extends module:acebase-core/AceBaseBase */ -class AceBaseClient extends AceBaseBase { +export class AceBaseClient extends AceBaseBase { + + /** + * @internal (for internal use) + */ + api: WebApi; + + auth: AceBaseClientAuth; /** * Create a client to access an AceBase server - * @param {AceBaseClientConnectionSettings} settings */ - constructor(settings) { - if (typeof settings !== 'object') { + constructor(init: ConnectionSettingsInit) { + if (typeof init !== 'object') { // Use old constructor signature: host, port, dbname, https = true - settings = {}; - settings.host = arguments[0]; - settings.port = arguments[1]; - settings.dbname = arguments[2]; - settings.https = arguments[3]; - } - if (!(settings instanceof AceBaseClientConnectionSettings)) { - settings = new AceBaseClientConnectionSettings(settings); + // eslint-disable-next-line prefer-rest-params + const [ host, port, dbname, https ] = arguments; + init = { host, port, dbname, https }; } + const settings = init instanceof AceBaseClientConnectionSettings ? init : new AceBaseClientConnectionSettings(init); super(settings.dbname, { info: 'realtime database client', sponsor: settings.sponsor }); /* - TODO: improve init flow with await/async (requires Node 7.6+) + TODO: improve init flow with await/async (requires Node 7.6+) */ - const cacheDb = settings.cache && settings.cache.db; + const cacheDb = settings.cache?.db; const cacheReadyPromise = cacheDb ? cacheDb.ready() : Promise.resolve(); let ready = false; @@ -92,7 +194,7 @@ class AceBaseClient extends AceBaseBase { const synchronizeClocks = async () => { // Synchronize date/time // const start = Date.now(); // performance.now(); - const info = await this.api.getServerInfo() + const info = await this.api.getServerInfo(); const now = Date.now(), // roundtrip = now - start, //performance.now() - start, // expectedTime = now - Math.floor(roundtrip / 2), @@ -103,34 +205,39 @@ class AceBaseClient extends AceBaseBase { this.on('connect', () => { // Disable cache db's ipc events, we are already notified of data changes by the server (prevents double event callbacks) - if (cacheDb) { cacheDb.settings.ipcEvents = false; } + if (cacheDb && 'settings' in (cacheDb as any)) { + (cacheDb as any).settings.ipcEvents = false; + } synchronizeClocks(); }); this.on('disconnect', () => { // Enable cache db's ipc events, so we get event notifications of changes by other ipc peers while offline - if (cacheDb) { cacheDb.settings.ipcEvents = true; } + if (cacheDb && 'settings' in (cacheDb as any)) { + (cacheDb as any).settings.ipcEvents = true; + } }); - this.sync = () => { - return syncPendingChanges(true); - } - + this.sync = async () => { + const result = await syncPendingChanges(true); + return result as Required>>; + }; + let syncRunning = false, firstSync = true; const syncPendingChanges = async (throwErrors = false) => { - if (syncRunning) { + if (syncRunning) { // Already syncing if (throwErrors) { throw new Error('sync already running'); } - return; + return; } if (!this.api.isConnected) { // We'll retry once connected - // // Do set firstSync to false, this fixes the issue of the first sync firing after - // // an initial succesful connection, but quick disconnect (sync does not run) + // // Do set firstSync to false, this fixes the issue of the first sync firing after + // // an initial succesful connection, but quick disconnect (sync does not run) // // and later reconnect --> Fresh data needs to be loaded - // firstSync = false; + // firstSync = false; if (throwErrors) { throw new Error('not connected'); } - return; + return; } syncRunning = true; try { @@ -141,7 +248,7 @@ class AceBaseClient extends AceBaseBase { eventCallback: (eventName, args) => { this.debug.log(eventName, args || ''); this.emit(eventName, args); // this.emit('cache_sync_event', { name: eventName, args }); - } + }, }); } catch(err) { @@ -155,8 +262,8 @@ class AceBaseClient extends AceBaseBase { syncRunning = false; firstSync = false; } - } - let syncTimeout = 0; + }; + let syncTimeout: NodeJS.Timeout; this.on('connect', () => { if (settings.sync.timing === 'connect' || (settings.sync.timing === 'signin' && this.auth.accessToken)) { syncPendingChanges(); @@ -180,7 +287,16 @@ class AceBaseClient extends AceBaseBase { this.emit('ready'); }; - this.api = new WebApi(settings.dbname, { network: settings.network, sync: settings.sync, logLevel: settings.logLevel, debug: this.debug, url: `http${settings.https ? 's' : ''}://${settings.host}:${settings.port}`, autoConnect: settings.autoConnect, autoConnectDelay: settings.autoConnectDelay, cache: settings.cache }, (evt, data) => { + this.api = new WebApi(settings.dbname, { + network: settings.network, + sync: settings.sync, + logLevel: settings.logLevel, + autoConnect: settings.autoConnect, + autoConnectDelay: settings.autoConnectDelay, + cache: settings.cache, + debug: this.debug, + url: `http${settings.https ? 's' : ''}://${settings.host}:${settings.port}`, + }, (evt, data) => { if (evt === 'connect') { this.emit('connect'); if (!ready) { @@ -192,7 +308,7 @@ class AceBaseClient extends AceBaseBase { if (!ready && cacheDb) { // If cache db is used, we can work without connection emitClientReady(); } - } + } else if (evt === 'disconnect') { this.emit('disconnect'); } @@ -202,6 +318,10 @@ class AceBaseClient extends AceBaseBase { }); } + async sync(): ReturnType { + throw new Error('Must be set by constructor'); + } + get connected() { return this.api.isConnected; } @@ -222,7 +342,7 @@ class AceBaseClient extends AceBaseBase { this.disconnect(); } - callExtension(method, path, data) { + callExtension(method: 'GET' | 'POST' | 'PUT' | 'DELETE', path: string, data: any) { return this.api.callExtension(method, path, data); } @@ -230,51 +350,50 @@ class AceBaseClient extends AceBaseBase { * Gets the current sync cursor */ getCursor() { - return this.api._syncCursor; + return this.api.getSyncCursor(); } /** * Sets the sync cursor to use - * @param {string} cursor */ - setCursor(cursor) { - this.api._syncCursor = cursor; + setCursor(cursor: string) { + this.api.setSyncCursor(cursor); } get cache() { /** * Clears the entire cache, or a specific path without raising any events - * @param {string} [path] - * @returns */ const clear = async (path = '') => { await this.api.clearCache(path); }; + /** * Updates the local cache with remote changes by retrieving all changes to `path` since given `cursor` and applying them to the local cache database. * If the local path does not exist or no cursor is given, its entire value will be loaded from the server and stored in cache. If no cache database is used, an error will be thrown. - * @param {string} [path=''] Path to update. The root path will be used if not given, synchronizing the entire database. - * @param {string|null} [cursor] A previously achieved cursor to update with. Path's entire value will be loaded from the server if not given. - * @returns {Promise<{ path: string, used_cursor: string, new_cursor: string, loaded_value: boolean, changes: Array<{ path: string, previous: any, value: any, context: any }> }>} + * @param path Path to update. The root path will be used if not given, synchronizing the entire database. + * @param cursor A previously achieved cursor to update with. Path's entire value will be loaded from the server if not given. */ - const update = (path, cursor) => { + const update = (path = '', cursor: string | null) => { return this.api.updateCache(path, cursor); }; + /** - * Loads a value from cache database.If a cursor is provided, the cache will be updated with remote changes + * Loads a value from cache database.If a cursor is provided, the cache will be updated with remote changes * first. If the value is not available in cache, it will be loaded from the server and stored in cache. - * This method is a shortcut for common cache logic provided by `db.ref(path).get` with the `cache_mode` + * This method is a shortcut for common cache logic provided by `db.ref(path).get` with the `cache_mode` * and `cache_cursor` options. - * @param {string} path target path to load - * @param {string} [cursor] A previously acquired cursor - * @returns {Promise} Returns a Promise that resolves with a snapshot of the value + * @param path target path to load + * @param cursor A previously acquired cursor + * @returns Returns a Promise that resolves with a snapshot of the value */ - const get = async (path, cursor) => { + const get = async (path: string, cursor: string | null): Promise => { // Update the cache with provided cursor - return this.ref(path).get({ cache_mode: cursor ? 'allow' : 'force', cache_cursor: cursor }); + const options = Object.freeze(cursor ? { cache_mode: 'allow', cache_cursor: cursor } : { cache_mode: 'force' }); + const snap = await this.ref(path).get(options); + return snap; }; + return { clear, update, get }; } } - -module.exports = { AceBaseClient, AceBaseClientConnectionSettings }; \ No newline at end of file diff --git a/src/ts/api-web.ts b/src/ts/api-web.ts index 4d3a0a4..d44d360 100644 --- a/src/ts/api-web.ts +++ b/src/ts/api-web.ts @@ -1,36 +1,63 @@ -const { Api, Transport, ID, PathInfo, ColorStyle, SchemaDefinition, SimpleEventEmitter } = require('acebase-core'); -const connectSocket = require('socket.io-client'); -const Base64 = require('./base64'); -const { AceBaseRequestError, NOT_CONNECTED_ERROR_MESSAGE } = require('./request/error'); -const { CachedValueUnavailableError } = require('./errors'); -const { promiseTimeout } = require('./promise-timeout'); -const _request = require('./request'); -const _websocketRequest = (socket, event, data, accessToken) => { +import { Api, Transport, ID, PathInfo, ColorStyle, SchemaDefinition, SimpleEventEmitter, EventSubscriptionCallback, AceBaseBase, DebugLogger, Query, QueryOptions } from 'acebase-core'; +import { connect as connectSocket } from 'socket.io-client'; +import * as Base64 from './base64'; +import { AceBaseRequestError, NOT_CONNECTED_ERROR_MESSAGE } from './request/error'; +import { CachedValueUnavailableError } from './errors'; +import { promiseTimeout } from './promise-timeout'; +import _request from './request'; +import { AceBaseUser } from './user'; +import { ValueChange, ValueMutation } from 'acebase-core/src/api'; +import { AceBaseClientConnectionSettings } from './acebase-client'; +import { IDataMutationsArray } from 'acebase-core/dist/types/data-snapshot'; +type IOWebSocket = ReturnType; +export interface IAceBaseAuthProviderSignInResult { + user: AceBaseUser; + accessToken: string; + provider: IAceBaseAuthProviderTokens; +} + +export interface IAceBaseAuthProviderTokens { + name: string; + access_token: string; + refresh_token: string; + expires_in: number; +} + +const _websocketRequest = >(socket: IOWebSocket | null, event: string, data: any, accessToken: string | null) => { + if (!socket) { + throw new Error(`Cannot send request because websocket connection is not open`); + } const requestId = ID.generate(); - const request = data; - request.req_id = requestId; - request.access_token = accessToken; + // const request = data; + // request.req_id = requestId; + // request.access_token = accessToken; + const request = { ...data, req_id: requestId, access_token: accessToken }; - return new Promise((resolve, reject) => { + type T = ResponseType & { + req_id: string; + success: boolean; + reason?: string | { code: string; message: string }; + }; + return new Promise((resolve, reject) => { if (!socket) { return reject(new AceBaseRequestError(request, null, 'websocket', 'No open websocket connection')); } - let timeout; - const send = (retry = 0) => { + let timeout: NodeJS.Timeout; + const send = (retry = 0) => { socket.emit(event, request); timeout = setTimeout(() => { if (retry < 2) { return send(retry+1); } - socket.off("result", handle); + socket.off('result', handle); const err = new AceBaseRequestError(request, null, 'timeout', `Server did not respond to "${event}" request after ${retry+1} tries`); reject(err); }, 1000); }; - const handle = response => { + const handle = (response: T) => { if (response.req_id === requestId) { clearTimeout(timeout); - socket.off("result", handle); + socket.off('result', handle); if (response.success) { return resolve(response); } @@ -40,46 +67,42 @@ const _websocketRequest = (socket, event, data, accessToken) => { const err = new AceBaseRequestError(request, response, code, message); reject(err); } - } - socket.on("result", handle); + }; + socket.on('result', handle); send(); }); -} +}; + +type EventSubscriptionSettings = { + newOnly: boolean; + cancelCallback: (reason: Error) => any; + syncFallback: 'reload' | (() => any|Promise) +}; /** - * @typedef {((err: Error, path:string, newValue:any, oldValue:any, context: any) => any)} EventSubscriptionCallback - * @typedef {{ newOnly: boolean, cancelCallback: (reason: Error) => any, syncFallback: 'reload'|(() => any|Promise)}} EventSubscriptionSettings + * TODO: Find out if we can use acebase-core's EventSubscription, extended with some properties */ - class EventSubscription { - /** - * - * @param {string} path - * @param {string} event - * @param {EventSubscriptionCallback} callback - * @param {EventSubscriptionSettings} settings - */ - constructor(path, event, callback, settings) { - /** @type {string} */this.path = path; - /** @type {string} */this.event = event; - /** @type {EventSubscriptionCallback}*/this.callback = callback; - /** @type {EventSubscriptionSettings} */this.settings = settings; - /** @type {'requested'|'active'|'canceled'} */this.state = 'requested'; - /** @type {number} */this.added = Date.now(); - /** @type {number} */this.activated = 0; - /** @type {number} */this.lastEvent = 0; - /** @type {number} */this.lastSynced = 0; - /** @type {string} */this.cursor = null; - /** @type {EventSubscriptionCallback}*/this.cacheCallback = null; - /** @type {EventSubscriptionCallback}*/this.tempCallback = null; + public state: 'requested' | 'active' | 'canceled' = 'requested'; + public added: number = Date.now(); + public activated = 0; + public lastEvent = 0; + public lastSynced = 0; + public cursor: string | null = null; + public cacheCallback: EventSubscriptionCallback | null = null; + public tempCallback?: EventSubscriptionCallback; + + constructor(public path: string, public event: string, public callback: EventSubscriptionCallback, public settings: EventSubscriptionSettings) { } + activate() { this.state = 'active'; if (this.activated === 0) { this.activated = Date.now(); } } - cancel(reason) { + + cancel(reason: Error) { this.state = 'canceled'; this.settings.cancelCallback(reason); } @@ -90,878 +113,942 @@ const CONNECTION_STATE_CONNECTING = 'connecting'; const CONNECTION_STATE_CONNECTED = 'connected'; const CONNECTION_STATE_DISCONNECTING = 'disconnecting'; -/** - * TODO: Use AceBaseClientSettings instead when refactoring to TypeScript - * @typedef IWebApiSettings - * @property {'verbose'|'log'|'warn'|'error'} logLevel - * @property {typeof console} debug - * @property {string} url - * @property {boolean} [autoConnect=true] - * @property {number} [autoConnectDelay=0] - * @property {{ db: AceBase }} [cache] - * @property {{ monitor: boolean, interval: number, transports: Array<'polling','interval'>, realtime: boolean }} network - * @property {{ timing: 'connect'|'signin'|'auto'|'manual', useCursor: boolean }} sync - */ +// eslint-disable-next-line @typescript-eslint/no-empty-function +const NOOP = () => {}; + +// /** +// * TODO: Use AceBaseClientSettings instead +// */ +// type IWebApiSettings = { +// logLevel: 'verbose' | 'log' | 'warn' | 'error'; +// debug: DebugLogger; +// url: string; +// /** @default true */ +// autoConnect?: boolean; +// /** @default 0 */ +// autoConnectDelay?: number; +// cache?: { db: AceBaseBase; priority?: 'cache' | 'server'; enabled?: boolean; }; +// network?: { monitor: boolean, interval: number, transports: Array<'websocket' | 'polling'>, realtime: boolean }; +// sync?: { timing: 'connect' | 'signin' | 'auto' | 'manual', useCursor: boolean }; +// }; + +type PendingMutation = { + /** type of mutation. `update` is not used anymore, those are stored as multiple `set` and `remove` mutations */ + type: T; + path: string; + data: any; + context: any; +}; /** * Api to connect to a remote AceBase server over http(s) */ -class WebApi extends Api { +export class WebApi extends Api { + + private _id = ID.generate(); // For mutation contexts, not using websocket client id because that might cause security issues + private socket: IOWebSocket | null = null; + private _autoConnect: boolean; + private _autoConnectDelay: number; + private _connectionState: typeof CONNECTION_STATE_DISCONNECTED | typeof CONNECTION_STATE_CONNECTING | typeof CONNECTION_STATE_CONNECTED | typeof CONNECTION_STATE_DISCONNECTING | typeof CONNECTION_STATE_DISCONNECTED; + private _serverVersion = 'unknown'; + + private _cursor = { + /** Last cursor received by the server */ + current: null as string | null, + /** Last cursor received before client went offline, will be used for sync. */ + sync: null as string | null, + }; /** - * - * @param {string} dbname - * @param {IWebApiSettings} settings - * @param {(event: string, ...args: any[]) => void} callback + * Allow cursor used for synchronization to be changed. Should only be done while not connected. */ - constructor(dbname = "default", settings, callback) { + public setSyncCursor(cursor: string | null) { + this._cursor.sync = cursor; + } + public getSyncCursor() { + return this._cursor.sync; + } + + private _eventTimeline = { init: Date.now(), connect: 0, signIn: 0, sync: 0, disconnect: 0 }; + + public get url() { return this.settings.url; } + + private async _updateCursor (cursor: string | null) { + if (!cursor || (this._cursor.current && cursor < this._cursor.current)) { + return; // Just in case this ever happens, ignore events with earlier cursors. + } + this._cursor.current = cursor; + } + + private _cache?: { + db: AceBaseBase, + priority: 'cache' | 'server', + }; + private get hasCache() { return !!this._cache; } + private get cache() { + if (!this._cache) { throw new Error('DEV ERROR: no cache db is used'); } + return this._cache; + } + + private _subscriptions: { [path: string]: EventSubscription[] } = {}; + private _realtimeQueries: any = {}; + private debug: DebugLogger; + private accessToken: string | null = null; + + private manualConnectionMonitor = new SimpleEventEmitter(); + private eventCallback: (event: string, ...args: any[]) => void; + + constructor( + private dbname = 'default', + private settings: + Pick + & { debug: DebugLogger, url: string }, + callback: (event: string, ...args: any[]) => void, + ) { // operations are done through http calls, // events are triggered through a websocket super(); this._id = ID.generate(); // For mutation contexts, not using websocket client id because that might cause security issues - this.url = settings.url; - this.socket = null; this._autoConnect = typeof settings.autoConnect === 'boolean' ? settings.autoConnect : true; this._autoConnectDelay = typeof settings.autoConnectDelay === 'number' ? settings.autoConnectDelay : 0; - this.dbname = dbname; this._connectionState = CONNECTION_STATE_DISCONNECTED; - this._serverVersion = 'unknown'; - this._cursor = { - /** Last cursor received by the server */ - current: null, - /** Last cursor received before client went offline, will be used for sync. */ - sync: null - }; - this._updateCursor = async cursor => { - if (!cursor || (this._cursor.current && cursor < this._cursor.current)) { - return; // Just in case this ever happens, ignore events with earlier cursors. - } - // console.log(`Updating sync cursor to ${cursor}`); - this._cursor.current = cursor; - // if (this._cache && this._cache.db) { - // await this._cache.db.api.set(`${this.dbname}/cursor`, cursor) - // .catch(err => { - // console.error(`Can't store cursor?`, err); - // }); - // } - }; - this._eventTimeline = { init: Date.now(), connect: 0, signIn: 0, sync: 0, disconnect: 0 }; - if (settings.cache && settings.cache.enabled !== false) { + + if (settings.cache.enabled !== false) { this._cache = { - db: settings.cache.db, - priority: settings.cache.priority || 'server' + db: settings.cache.db as AceBaseBase, + priority: settings.cache.priority, }; - // this._cache.db.api.get(`${this.dbname}/cursor`).then(({ value: cursor }) => { - // this._syncCursor = cursor; - // }); - } - - const manualConnectionMonitor = new SimpleEventEmitter(); - const checkConnection = async () => { - // Websocket connection is used - if (settings.network.realtime && !this.isConnected) { - // socket.io handles reconnects, we don't have to monitor - return; - } - if (!settings.network.realtime && ![CONNECTION_STATE_CONNECTING, CONNECTION_STATE_CONNECTED].includes(this._connectionState)) { - // No websocket connection. Do not check if we're not connecting or connected - return; - } - const wasConnected = this.isConnected; - try { - // Websocket is connected (or realtime is not used), check connectivity to server by sending http/s ping - await this._request({ url: this.serverPingUrl, ignoreConnectionState: true }); - if (!wasConnected) { - manualConnectionMonitor.emit('connect'); - } - } - catch (err) { - // No need to handle error here, _request will have handled the disconnect by calling this._handleDetectedDisconnect - } - }; - this._handleDetectedDisconnect = () => { - if (settings.network.realtime) { - // Launch reconnect flow by recycling the websocket - this._connectionState === CONNECTION_STATE_DISCONNECTED; - this.connect().catch(() => {}); - // console.assert(this._connectionState === CONNECTION_STATE_CONNECTING, 'wrong connection state'); - } - else { - if (this._connectionState === CONNECTION_STATE_CONNECTING) { - manualConnectionMonitor.emit('connect_error', err); - } - else if (this._connectionState === CONNECTION_STATE_CONNECTED) { - manualConnectionMonitor.emit('disconnect'); - } - } } + if (settings.network.monitor) { // Mobile devices might go offline while the app is suspended (running in the backgound) // no events will fire and when the app resumes, it might assume it is still connected while // it is not. We'll manually poll the server to check the connection - const interval = setInterval(checkConnection, settings.network.interval * 1000); // ping every x seconds + const interval = setInterval(() => this.checkConnection(), settings.network.interval * 1000); // ping every x seconds interval.unref && interval.unref(); } - this.settings = settings; - this._realtimeQueries = {}; - /** @type {typeof settings.debug} */ this.debug = settings.debug; - const eventCallback = (event, ...args) => { + this.eventCallback = (event, ...args) => { if (event === 'disconnect') { this._cursor.sync = this._cursor.current; } callback && callback(event, ...args); }; - /** @type {{ [path: string]: EventSubscription[] }} */ - let subscriptions = this._subscriptions = {}; - let accessToken; - this.connect = () => { - if (this.socket !== null && typeof this.socket === 'object') { - this.disconnect(); + if (this._autoConnect) { + if (this._autoConnectDelay) { setTimeout(() => this.connect().catch(NOOP), this._autoConnectDelay); } + else { this.connect().catch(NOOP); } + } + } + + private async checkConnection() { + // Websocket connection is used + if (this.settings.network?.realtime && !this.isConnected) { + // socket.io handles reconnects, we don't have to monitor + return; + } + if (!this.settings.network?.realtime && ![CONNECTION_STATE_CONNECTING, CONNECTION_STATE_CONNECTED].includes(this._connectionState)) { + // No websocket connection. Do not check if we're not connecting or connected + return; + } + const wasConnected = this.isConnected; + try { + // Websocket is connected (or realtime is not used), check connectivity to server by sending http/s ping + await this._request({ url: this.serverPingUrl, ignoreConnectionState: true }); + if (!wasConnected) { + this.manualConnectionMonitor.emit('connect'); } - this._connectionState = CONNECTION_STATE_CONNECTING; - this.debug.log(`Connecting to AceBase server "${this.url}"`); - if (!this.url.startsWith('https')) { - this.debug.warn(`WARNING: The server you are connecting to does not use https, any data transferred may be intercepted!`.colorize(ColorStyle.red)); + } + catch (err) { + // No need to handle error here, _request will have handled the disconnect by calling this._handleDetectedDisconnect + } + } + + private _handleDetectedDisconnect(err?: Error) { + if (this.settings.network?.realtime) { + // Launch reconnect flow by recycling the websocket + this._connectionState === CONNECTION_STATE_DISCONNECTED; + this.connect().catch(NOOP); + // console.assert(this._connectionState === CONNECTION_STATE_CONNECTING, 'wrong connection state'); + } + else { + if (this._connectionState === CONNECTION_STATE_CONNECTING) { + this.manualConnectionMonitor.emit('connect_error', err); } - - // Change default socket.io (engine.io) transports setting of ['polling', 'websocket'] - // We should only use websocket (it's almost 2022!), because if an AceBaseServer is running in a cluster, - // polling should be disabled entirely because the server is not stateless: the client might reach - // a different node on a next long-poll connection. - // For backward compatibility the transports setting is allowed to be overriden with a setting: - const transports = settings.network && settings.network.transports instanceof Array - ? settings.network.transports - : ['websocket']; - this.debug.log(`Using ${transports.join(',')} transport${transports.length > 1 ? 's' : ''} for socket.io`); + else if (this._connectionState === CONNECTION_STATE_CONNECTED) { + this.manualConnectionMonitor.emit('disconnect'); + } + } + } - return new Promise((resolve, reject) => { + public connect() { + if (this.socket !== null && typeof this.socket === 'object') { + this.disconnect(); + } + this._connectionState = CONNECTION_STATE_CONNECTING; + this.debug.log(`Connecting to AceBase server "${this.url}"`); + if (!this.url.startsWith('https')) { + this.debug.warn(`WARNING: The server you are connecting to does not use https, any data transferred may be intercepted!`.colorize(ColorStyle.red)); + } - if (!settings.network.realtime) { - // New option: not using websocket connection. Check if we can reach the server. + // Change default socket.io (engine.io) transports setting of ['polling', 'websocket'] + // We should only use websocket (it's almost 2022!), because if an AceBaseServer is running in a cluster, + // polling should be disabled entirely because the server is not stateless: the client might reach + // a different node on a next long-poll connection. + // For backward compatibility the transports setting is allowed to be overriden with a setting: + const transports = this.settings.network?.transports instanceof Array + ? this.settings.network.transports + : ['websocket']; + this.debug.log(`Using ${transports.join(',')} transport${transports.length > 1 ? 's' : ''} for socket.io`); - // Make sure any previously attached events are removed - manualConnectionMonitor.off('connect'); - manualConnectionMonitor.off('connect_error'); - manualConnectionMonitor.off('disconnect'); + return new Promise((resolve, reject) => { + if (!this.settings.network?.realtime) { + // New option: not using websocket connection. Check if we can reach the server. - manualConnectionMonitor.on('connect', () => { - this._connectionState = CONNECTION_STATE_CONNECTED; - this._eventTimeline.connect = Date.now(); - manualConnectionMonitor.off('connect_error'); // prevent connect_error to fire after a successful connect - eventCallback('connect'); - resolve(); - }); - manualConnectionMonitor.on('connect_error', (err) => { - // New connection failed to establish. Attempts will be made to reconnect, but fail for now - this.debug.error(`API connection error: ${err.message || err}`); - eventCallback('connect_error', err); - reject(err); - }); - manualConnectionMonitor.on('disconnect', () => { - // Existing connection was broken, by us or network - if (this._connectionState === CONNECTION_STATE_DISCONNECTING) { - // Disconnect was requested by us: reason === 'client namespace disconnect' - this._connectionState = CONNECTION_STATE_DISCONNECTED; - // Remove event listeners - manualConnectionMonitor.off('connect'); - manualConnectionMonitor.off('disconnect'); - manualConnectionMonitor.off('connect_error'); - } - else { - // Disconnect was not requested. - this._connectionState = CONNECTION_STATE_CONNECTING; - this._eventTimeline.disconnect = Date.now(); - } - eventCallback('disconnect'); - }); - this._connectionState = CONNECTION_STATE_CONNECTING; - return setTimeout(() => checkConnection(), 0); - } - - const socket = this.socket = connectSocket(this.url, { - // Use default socket.io connection settings: - autoConnect: true, - reconnection: true, - reconnectionAttempts: Infinity, - reconnectionDelay: 1000, - reconnectionDelayMax: 5000, - timeout: 20000, - randomizationFactor: 0.5, - transports // Override default setting of ['polling', 'websocket'] - }); + // Make sure any previously attached events are removed + this.manualConnectionMonitor.off('connect'); + this.manualConnectionMonitor.off('connect_error'); + this.manualConnectionMonitor.off('disconnect'); - socket.on('connect_error', err => { - // New connection failed to establish. Attempts will be made to reconnect, but fail for now - this.debug.error(`Websocket connection error: ${err}`); - eventCallback('connect_error', err); - reject(err); - }); - - socket.on('connect', async data => { + this.manualConnectionMonitor.on('connect', () => { this._connectionState = CONNECTION_STATE_CONNECTED; this._eventTimeline.connect = Date.now(); - - if (accessToken) { - // User must be signed in again (NOTE: this does not emit the "signin" event if the user was signed in before) - const isFirstSignIn = this._eventTimeline.signIn === 0; - try { - await this.signInWithToken(accessToken, isFirstSignIn); - } - catch(err) { - this.debug.error(`Could not automatically sign in user with access token upon reconnect: ${err.code || err.message}`); - } - } - - /** - * @param {EventSubscription} sub - * @returns {Promise} - */ - const subscribeTo = async (sub) => { - // Function is called for each unique path/event combination - // We must activate or cancel all subscriptions with this combination - const subs = subscriptions[sub.path].filter(s => s.event === sub.event); - try { - const result = await _websocketRequest(this.socket, 'subscribe', { path: sub.path, event: sub.event }, accessToken); - subs.forEach(s => s.activate()); - } - catch(err) { - if (err.code === 'access_denied' && !accessToken) { - this.debug.error(`Could not subscribe to event "${sub.event}" on path "${sub.path}" because you are not signed in. If you added this event while offline and have a user access token, you can prevent this by using client.auth.setAccessToken(token) to automatically try signing in after connecting`); - } - else { - this.debug.error(err); - } - subs.forEach(s => s.cancel(err)); - } - }; - - // (re)subscribe to any active subscriptions - const subscribePromises = []; - Object.keys(subscriptions).forEach(path => { - const events = []; - subscriptions[path].forEach(sub => { - if (sub.event === 'mutated') { return; } // Skip mutated events for now - const serverAlreadyNotifying = events.includes(sub.event); - if (!serverAlreadyNotifying) { - events.push(sub.event); - const promise = subscribeTo(sub); - subscribePromises.push(promise); - } - }); - }); - - // Now, subscribe to all top path mutated events - const subscribeToMutatedEvents = async () => { - let retry = false; - const promises = Object.keys(subscriptions) - .filter(path => subscriptions[path].some(sub => sub.event === 'mutated' && sub.state !== 'canceled')) - .filter((path, i, arr) => !arr.some(otherPath => PathInfo.get(otherPath).isAncestorOf(path))) - .reduce((topPaths, path) => (topPaths.includes(path) || topPaths.push(path)) && topPaths, []) - .map(topEventPath => { - const sub = subscriptions[topEventPath].find(s => s.event === 'mutated'); - const promise = subscribeTo(sub).then(() => { - if (sub.state === 'canceled') { - // Oops, could not subscribe to 'mutated' event on topEventPath, other event(s) at child path(s) should now take over - retry = true; - } - }); - return promise; - }); - await Promise.all(promises); - if (retry) { - return subscribeToMutatedEvents(); - } - } - subscribePromises.push(subscribeToMutatedEvents()); - await Promise.all(subscribePromises); - - eventCallback('connect'); // Safe to let client know we're connected - resolve(); // Resolve the .connect() promise + this.manualConnectionMonitor.off('connect_error'); // prevent connect_error to fire after a successful connect + this.eventCallback('connect'); + resolve(); }); - - socket.on('disconnect', reason => { - this.debug.warn(`Websocket disconnected: ${reason}`); + this.manualConnectionMonitor.on('connect_error', (err) => { + // New connection failed to establish. Attempts will be made to reconnect, but fail for now + this.debug.error(`API connection error: ${err.message || err}`); + this.eventCallback('connect_error', err); + reject(err); + }); + this.manualConnectionMonitor.on('disconnect', () => { // Existing connection was broken, by us or network if (this._connectionState === CONNECTION_STATE_DISCONNECTING) { - // disconnect was requested by us: reason === 'client namespace disconnect' + // Disconnect was requested by us: reason === 'client namespace disconnect' this._connectionState = CONNECTION_STATE_DISCONNECTED; + // Remove event listeners + this.manualConnectionMonitor.off('connect'); + this.manualConnectionMonitor.off('disconnect'); + this.manualConnectionMonitor.off('connect_error'); } else { - // Automatic reconnect should be done by socket.io + // Disconnect was not requested. this._connectionState = CONNECTION_STATE_CONNECTING; this._eventTimeline.disconnect = Date.now(); - if (reason === 'io server disconnect') { - // if the server has shut down and disconnected all clients, we have to reconnect manually - this.socket = null; - this.connect().catch(err => { - // Immediate reconnect failed, which is ok. - // socket.io will retry now - }); - } } - eventCallback('disconnect'); + this.eventCallback('disconnect'); }); + this._connectionState = CONNECTION_STATE_CONNECTING; + return setTimeout(() => this.checkConnection(), 0); + } - socket.on('data-event', data => { - const val = Transport.deserialize(data.val); - const context = data.context || {}; - context.acebase_event_source = 'server'; - this._updateCursor(context.acebase_cursor); // If the server passes a cursor, it supports transaction logging. Save it for sync later on + const socket = this.socket = connectSocket(this.url, { + // Use default socket.io connection settings: + autoConnect: true, + reconnection: true, + reconnectionAttempts: Infinity, + reconnectionDelay: 1000, + reconnectionDelayMax: 5000, + timeout: 20000, + randomizationFactor: 0.5, + transports, // Override default setting of ['polling', 'websocket'] + }); - /* - Using the new context, we can determine how we should handle this data event. - From client v0.9.29 on, the set and update API methods add an acebase_mutation object - to the context with the following info: + socket.on('connect_error', err => { + // New connection failed to establish. Attempts will be made to reconnect, but fail for now + this.debug.error(`Websocket connection error: ${err}`); + this.eventCallback('connect_error', err); + reject(err); + }); - client_id: which client initiated the mutation (web api instance, also different per browser tab) - id: a unique id of the mutation - op: operation used: 'set' or 'update' - path: the path the operation was executed on - flow: the flow used: - - 'server': app was connected, cache was not used. - - 'cache': app was offline while mutating, now syncs its change - - 'parallel': app was connected, cache was used and updated + socket.on('connect', async () => { + this._connectionState = CONNECTION_STATE_CONNECTED; + this._eventTimeline.connect = Date.now(); - To determine how to handle this data event, we have to know what events may have already - been fired. - - [Mutation initiated:] - - Cache database used? - - No -> 'server' flow - - Yes -> Client was online/connected? - - No -> 'cache' flow (saved to cache db, sycing once connected) - - Yes -> 'parallel' flow - - During 'cache' and 'parallel' flow, any change events will have fired on the cache database - already. If we are receiving this data event on the same client, that means we don't have to - fire those events again. If we receive this event on a different client, we only have to fire - events if they change cached data. - - [Change event received:] - - Is mutation done by us? - - No -> Are we using cache? - - No -> Fire events - - Yes -> Update cache with events disabled*, fire events - - Yes -> Are we using cache? - - No -> Fire events ourself - - Yes -> Skip cache update, don't fire events (both done already) - - * Different browser tabs use the same cache database. If we would let the cache database fire data change - events, they would only fire in 1 browser tab - the first one to update the cache, the others will see - no changes because the data will have been updated already. - - NOTE: While offline, the in-memory state of 2 separate browser tabs will go out of sync - because they rely on change notifications from the server - to tackle this problem, - cross-tab communication has been implemented. (TODO: let cache db's use the same client - ID for server communications) - */ - const causedByUs = context.acebase_mutation && context.acebase_mutation.client_id === this._id; - const cacheEnabled = !!(this._cache && this._cache.db); - const fireThisEvent = !causedByUs || !cacheEnabled; - const updateCache = !causedByUs && cacheEnabled; - const fireCacheEvents = false; // See above flow documentation - - // console.log(`${this._cache ? `[${this._cache.db.api.storage.name}] ` : ''}Received data event "${data.event}" on path "${data.path}":`, val); - // console.log(`Received data event "${data.event}" on path "${data.path}":`, val); - const pathSubs = subscriptions[data.subscr_path]; - - if (!pathSubs && data.event !== 'mutated') { - // NOTE: 'mutated' events fire on the mutated path itself. 'mutations' events fire on subscription path - - // We are not subscribed on this path. Happens when an event fires while a server unsubscribe - // has been requested, but not processed yet: the local subscription will be gone already. - // This can be confusing when using cache, an unsubscribe may have been requested after a cache - // event fired - the server event will follow but we're not listening anymore! - // this.debug.warn(`Received a data-event on a path we did not subscribe to: "${data.subscr_path}"`); - return; + if (this.accessToken) { + // User must be signed in again (NOTE: this does not emit the "signin" event if the user was signed in before) + const isFirstSignIn = this._eventTimeline.signIn === 0; + try { + await this.signInWithToken(this.accessToken, isFirstSignIn); } - if (updateCache) { - if (data.path.startsWith('__')) { - // Don't cache private data. This happens when the admin user is signed in - // and has an event subscription on the root, or private path. - // NOTE: fireThisEvent === true, because it is impossible that this mutation was caused by us (well, it should be!) - } - else if (data.event === 'mutations') { - // Apply all mutations - const mutations = val.current; - mutations.forEach(m => { - const path = m.target.reduce((path, key) => PathInfo.getChildPath(path, key), PathInfo.getChildPath(`${this.dbname}/cache`, data.path)); - this._cache.db.api.set(path, m.val, { suppress_events: !fireCacheEvents, context }); - }); - } - else if (data.event === 'notify_child_removed') { - this._cache.db.api.set(PathInfo.getChildPath(`${this.dbname}/cache`, data.path), null, { suppress_events: !fireCacheEvents, context }); // Remove cached value - } - else if (!data.event.startsWith('notify_')) { - this._cache.db.api.set(PathInfo.getChildPath(`${this.dbname}/cache`, data.path), val.current, { suppress_events: !fireCacheEvents, context }); // Update cached value - } + catch (err: any) { + this.debug.error(`Could not automatically sign in user with access token upon reconnect: ${err.code || err.message}`); } - if (!fireThisEvent) { - return; + } + + const subscribeTo = async (sub: EventSubscription) => { + // Function is called for each unique path/event combination + // We must activate or cancel all subscriptions with this combination + const subs = this._subscriptions[sub.path].filter(s => s.event === sub.event); + try { + const result = await _websocketRequest(this.socket, 'subscribe', { path: sub.path, event: sub.event }, this.accessToken); + subs.forEach(s => s.activate()); } - // The cache db will not have fired any events (const fireCacheEvents = false), so we can fire them here now. - /** @type {EventSubscription[]} */ - const targetSubs = data.event === 'mutated' - ? Object.keys(subscriptions) - .filter(path => { - const pathInfo = PathInfo.get(path); - return path === data.path || pathInfo.equals(data.subscr_path) || pathInfo.isAncestorOf(data.path) - }) - .reduce((subs, path) => { - const add = subscriptions[path].filter(sub => sub.event === 'mutated'); - subs.push(...add); - return subs; - }, []) - : pathSubs.filter(sub => sub.event === data.event); - - targetSubs.forEach(subscr => { - subscr.lastEvent = Date.now(); - subscr.cursor = context.acebase_cursor; - subscr.callback(null, data.path, val.current, val.previous, context); + catch (err: any) { + if (err.code === 'access_denied' && !this.accessToken) { + this.debug.error(`Could not subscribe to event "${sub.event}" on path "${sub.path}" because you are not signed in. If you added this event while offline and have a user access token, you can prevent this by using client.auth.setAccessToken(token) to automatically try signing in after connecting`); + } + else { + this.debug.error(err); + } + subs.forEach(s => s.cancel(err)); + } + }; + + // (re)subscribe to any active subscriptions + const subscribePromises = [] as Promise[]; + Object.keys(this._subscriptions).forEach(path => { + const events = [] as string[]; + this._subscriptions[path].forEach(sub => { + if (sub.event === 'mutated') { return; } // Skip mutated events for now + const serverAlreadyNotifying = events.includes(sub.event); + if (!serverAlreadyNotifying) { + events.push(sub.event); + const promise = subscribeTo(sub); + subscribePromises.push(promise); + } }); }); - socket.on("query-event", data => { - data = Transport.deserialize(data); - const query = this._realtimeQueries[data.query_id]; - let keepMonitoring = true; - try { - keepMonitoring = query.options.eventHandler(data); + // Now, subscribe to all top path mutated events + const subscribeToMutatedEvents = async () => { + let retry = false; + const promises = Object.keys(this._subscriptions) + .filter(path => this._subscriptions[path].some(sub => sub.event === 'mutated' && sub.state !== 'canceled')) + .filter((path, i, arr) => !arr.some(otherPath => PathInfo.get(otherPath).isAncestorOf(path))) + .reduce((topPaths, path) => (topPaths.includes(path) || topPaths.push(path) as 1) && topPaths, [] as string[]) + .map(topEventPath => { + const sub = this._subscriptions[topEventPath].find(s => s.event === 'mutated') as EventSubscription; + const promise = subscribeTo(sub).then(() => { + if (sub.state === 'canceled') { + // Oops, could not subscribe to 'mutated' event on topEventPath, other event(s) at child path(s) should now take over + retry = true; + } + }); + return promise; + }); + await Promise.all(promises); + if (retry) { + await subscribeToMutatedEvents(); } - catch(err) { - keepMonitoring = false; + }; + subscribePromises.push(subscribeToMutatedEvents()); + await Promise.all(subscribePromises); + + this.eventCallback('connect'); // Safe to let client know we're connected + resolve(); // Resolve the .connect() promise + }); + + socket.on('disconnect', reason => { + this.debug.warn(`Websocket disconnected: ${reason}`); + // Existing connection was broken, by us or network + if (this._connectionState === CONNECTION_STATE_DISCONNECTING) { + // disconnect was requested by us: reason === 'client namespace disconnect' + this._connectionState = CONNECTION_STATE_DISCONNECTED; + } + else { + // Automatic reconnect should be done by socket.io + this._connectionState = CONNECTION_STATE_CONNECTING; + this._eventTimeline.disconnect = Date.now(); + if (reason === 'io server disconnect') { + // if the server has shut down and disconnected all clients, we have to reconnect manually + this.socket = null; + this.connect().catch(err => { + // Immediate reconnect failed, which is ok. + // socket.io will retry now + }); } - if (keepMonitoring === false) { - delete this._realtimeQueries[data.query_id]; - socket.emit("query-unsubscribe", { query_id: data.query_id }); + } + this.eventCallback('disconnect'); + }); + + socket.on('data-event', data => { + const val = Transport.deserialize(data.val); + const context = data.context || {}; + context.acebase_event_source = 'server'; + this._updateCursor(context.acebase_cursor); // If the server passes a cursor, it supports transaction logging. Save it for sync later on + + /* + Using the new context, we can determine how we should handle this data event. + From client v0.9.29 on, the set and update API methods add an acebase_mutation object + to the context with the following info: + + client_id: which client initiated the mutation (web api instance, also different per browser tab) + id: a unique id of the mutation + op: operation used: 'set' or 'update' + path: the path the operation was executed on + flow: the flow used: + - 'server': app was connected, cache was not used. + - 'cache': app was offline while mutating, now syncs its change + - 'parallel': app was connected, cache was used and updated + + To determine how to handle this data event, we have to know what events may have already + been fired. + + [Mutation initiated:] + - Cache database used? + - No -> 'server' flow + - Yes -> Client was online/connected? + - No -> 'cache' flow (saved to cache db, sycing once connected) + - Yes -> 'parallel' flow + + During 'cache' and 'parallel' flow, any change events will have fired on the cache database + already. If we are receiving this data event on the same client, that means we don't have to + fire those events again. If we receive this event on a different client, we only have to fire + events if they change cached data. + + [Change event received:] + - Is mutation done by us? + - No -> Are we using cache? + - No -> Fire events + - Yes -> Update cache with events disabled*, fire events + - Yes -> Are we using cache? + - No -> Fire events ourself + - Yes -> Skip cache update, don't fire events (both done already) + + * Different browser tabs use the same cache database. If we would let the cache database fire data change + events, they would only fire in 1 browser tab - the first one to update the cache, the others will see + no changes because the data will have been updated already. + + NOTE: While offline, the in-memory state of 2 separate browser tabs will go out of sync + because they rely on change notifications from the server - to tackle this problem, + cross-tab communication has been implemented. (TODO: let cache db's use the same client + ID for server communications) + */ + const causedByUs = context.acebase_mutation?.client_id === this._id; + const cacheEnabled = this.hasCache; //!!this._cache?.db; + const fireThisEvent = !causedByUs || !cacheEnabled; + const updateCache = !causedByUs && cacheEnabled; + const fireCacheEvents = false; // See above flow documentation + + // console.log(`${this._cache ? `[${this._cache.db.api.storage.name}] ` : ''}Received data event "${data.event}" on path "${data.path}":`, val); + // console.log(`Received data event "${data.event}" on path "${data.path}":`, val); + const pathSubs = this._subscriptions[data.subscr_path]; + + if (!pathSubs && data.event !== 'mutated') { + // NOTE: 'mutated' events fire on the mutated path itself. 'mutations' events fire on subscription path + + // We are not subscribed on this path. Happens when an event fires while a server unsubscribe + // has been requested, but not processed yet: the local subscription will be gone already. + // This can be confusing when using cache, an unsubscribe may have been requested after a cache + // event fired - the server event will follow but we're not listening anymore! + // this.debug.warn(`Received a data-event on a path we did not subscribe to: "${data.subscr_path}"`); + return; + } + if (updateCache) { + if (data.path.startsWith('__')) { + // Don't cache private data. This happens when the admin user is signed in + // and has an event subscription on the root, or private path. + // NOTE: fireThisEvent === true, because it is impossible that this mutation was caused by us (well, it should be!) } + else if (data.event === 'mutations') { + // Apply all mutations + const mutations = val.current as IDataMutationsArray; + mutations.forEach(m => { + const path = m.target.reduce( + (path: string, key) => PathInfo.getChildPath(path, key), + PathInfo.getChildPath(`${this.dbname}/cache`, data.path as string) as string, + ); + this.cache.db.api.set(path, m.val, { suppress_events: !fireCacheEvents, context }); + }); + } + else if (data.event === 'notify_child_removed') { + this.cache.db.api.set(PathInfo.getChildPath(`${this.dbname}/cache`, data.path), null, { suppress_events: !fireCacheEvents, context }); // Remove cached value + } + else if (!data.event.startsWith('notify_')) { + this.cache.db.api.set(PathInfo.getChildPath(`${this.dbname}/cache`, data.path), val.current, { suppress_events: !fireCacheEvents, context }); // Update cached value + } + } + if (!fireThisEvent) { + return; + } + // The cache db will not have fired any events (const fireCacheEvents = false), so we can fire them here now. + const targetSubs = data.event === 'mutated' + ? Object.keys(this._subscriptions) + .filter(path => { + const pathInfo = PathInfo.get(path); + return path === data.path || pathInfo.equals(data.subscr_path) || pathInfo.isAncestorOf(data.path); + }) + .reduce((subs, path) => { + const add = this._subscriptions[path].filter(sub => sub.event === 'mutated'); + subs.push(...add); + return subs; + }, [] as EventSubscription[]) + : pathSubs.filter(sub => sub.event === data.event); + + targetSubs.forEach(subscr => { + subscr.lastEvent = Date.now(); + subscr.cursor = context.acebase_cursor; + subscr.callback(null, data.path, val.current, val.previous, context); }); }); + + socket.on('query-event', data => { + data = Transport.deserialize(data); + const query = this._realtimeQueries[data.query_id]; + let keepMonitoring = true; + try { + keepMonitoring = query.options.eventHandler(data); + } + catch(err) { + keepMonitoring = false; + } + if (keepMonitoring === false) { + delete this._realtimeQueries[data.query_id]; + socket.emit('query-unsubscribe', { query_id: data.query_id }); + } + }); + }); + } + + public disconnect() { + if (!this.settings.network?.realtime) { + // No websocket connectino is used + this._connectionState = CONNECTION_STATE_DISCONNECTING; + this._eventTimeline.disconnect = Date.now(); + this.manualConnectionMonitor.emit('disconnect'); + } + else if (this.socket !== null && typeof this.socket === 'object') { + this._connectionState = CONNECTION_STATE_DISCONNECTING; + this._eventTimeline.disconnect = Date.now(); + this.socket.disconnect(); + this.socket = null; + } + } + + public async subscribe(path: string, event: string, callback: EventSubscriptionCallback, settings: EventSubscriptionSettings): Promise { + if (!this.settings.network?.realtime) { + throw new Error(`Cannot subscribe to realtime events because it has been disabled in the network settings`); + } + let pathSubs = this._subscriptions[path]; + if (!pathSubs) { pathSubs = this._subscriptions[path] = []; } + const serverAlreadyNotifying = pathSubs.some(sub => sub.event === event) + || (event === 'mutated' && Object.keys(this._subscriptions).some(otherPath => PathInfo.get(otherPath).isAncestorOf(path) && this._subscriptions[otherPath].some(sub => sub.event === event && sub.state === 'active'))); + const subscr = new EventSubscription(path, event, callback, settings); + // { path, event, callback, settings, added: Date.now(), activate() { this.activated = Date.now() }, activated: null, lastEvent: null, cursor: null }; + pathSubs.push(subscr); + + if (this.hasCache) { + // Events are also handled by cache db + subscr.cacheCallback = (err, path, newValue, oldValue, context) => subscr.callback(err, path.slice(`${this.dbname}/cache/`.length), newValue, oldValue, context); + this.cache.db.api.subscribe(PathInfo.getChildPath(`${this.dbname}/cache`, path), event, subscr.cacheCallback); + } + + if (serverAlreadyNotifying || !this.isConnected) { + // If we're offline, the event will be subscribed once connected + return; + } + if (event === 'mutated') { + // Unsubscribe from 'mutated' events set on descendant paths of current path + Object.keys(this._subscriptions) + .filter(otherPath => + PathInfo.get(otherPath).isDescendantOf(path) + && this._subscriptions[otherPath].some(sub => sub.event === 'mutated'), + ) + .map(path => _websocketRequest(this.socket, 'unsubscribe', { path, event: 'mutated' }, this.accessToken)) + .map(promise => promise.catch(err => console.error(err))); + } + const result = await _websocketRequest(this.socket, 'subscribe', { path, event }, this.accessToken); + subscr.activate(); + // return result; + } + + public async unsubscribe(path: string, event?: string, callback?: EventSubscriptionCallback) { + if (!this.settings.network?.realtime) { + throw new Error(`Cannot unsubscribe from realtime events because it has been disabled in the network settings`); + } + const pathSubs = this._subscriptions[path]; + if (!pathSubs) { return Promise.resolve(); } + + const unsubscribeFrom = (subscriptions: EventSubscription[]) => { + subscriptions.forEach(subscr => { + pathSubs.splice(pathSubs.indexOf(subscr), 1); + if (this.hasCache) { + // Events are also handled by cache db, also remove those + if (typeof subscr.cacheCallback !== 'undefined') { throw new Error('DEV ERROR: When subscription was added, cacheCallback must have been set'); } + this.cache.db.api.unsubscribe(PathInfo.getChildPath(`${this.dbname}/cache`, path), subscr.event, subscr.cacheCallback); + } + }); }; - if (this._autoConnect) { - if (this._autoConnectDelay) { setTimeout(() => this.connect().catch(() => {}), this._autoConnectDelay); } - else { this.connect().catch(() => {}); } + const hadMutatedEvents = pathSubs.some(sub => sub.event === 'mutated'); + if (!event) { + // Unsubscribe from all events on path + unsubscribeFrom(pathSubs); } + else if (!callback) { + // Unsubscribe from specific event on path + const subscriptions = pathSubs.filter(subscr => subscr.event === event); + unsubscribeFrom(subscriptions); + } + else { + // Unsubscribe from a specific callback on path event + const subscriptions = pathSubs.filter(subscr => subscr.event === event && subscr.callback === callback); + unsubscribeFrom(subscriptions); + } + const hasMutatedEvents = pathSubs.some(sub => sub.event === 'mutated'); - this.disconnect = () => { - if (!settings.network.realtime) { - // No websocket connectino is used - this._connectionState = CONNECTION_STATE_DISCONNECTING; - this._eventTimeline.disconnect = Date.now(); - manualConnectionMonitor.emit('disconnect'); - } - else if (this.socket !== null && typeof this.socket === 'object') { - this._connectionState = CONNECTION_STATE_DISCONNECTING; - this._eventTimeline.disconnect = Date.now(); - this.socket.disconnect(); - this.socket = null; + let promise = Promise.resolve() as Promise; + if (pathSubs.length === 0) { + // Unsubscribed from all events on path + delete this._subscriptions[path]; + if (this.isConnected) { + promise = _websocketRequest(this.socket, 'unsubscribe', { path, access_token: this.accessToken }, this.accessToken) + .catch(err => this.debug.error(`Failed to unsubscribe from event(s) on "${path}": ${err.message}`)); } + } + else if (this.isConnected && !pathSubs.some(subscr => subscr.event === event)) { + // No callbacks left for specific event + promise = _websocketRequest(this.socket, 'unsubscribe', { path: path, event, access_token: this.accessToken }, this.accessToken) + .catch(err => this.debug.error(`Failed to unsubscribe from event "${event}" on "${path}": ${err.message}`)); + } + if (this.isConnected && hadMutatedEvents && !hasMutatedEvents) { + // If any descendant paths have mutated events, resubscribe those + const promises = Object.keys(this._subscriptions) + .filter(otherPath => PathInfo.get(otherPath).isDescendantOf(path) && this._subscriptions[otherPath].some(sub => sub.event === 'mutated')) + .map(path => _websocketRequest(this.socket, 'subscribe', { path: path, event: 'mutated' }, this.accessToken)) + .map(promise => promise.catch(err => this.debug.error(`Failed to subscribe to event "${event}" on path "${path}": ${err.message}`))); + promise = Promise.all([promise, ...promises]); + } + await promise; + } + + public transaction(path: string, callback: (val: any) => any, options = { context: {} as any }): Promise<{ cursor?: string }> { + const id = ID.generate(); + options.context = options.context || {}; + // TODO: reduce this contextual overhead to 'client_id' only, or additional debugging info upon request + options.context.acebase_mutation = { + client_id: this._id, + id, + op: 'transaction', + path, + flow: 'server', }; + const cachePath = PathInfo.getChildPath(`${this.dbname}/cache`, path); - /** - * - * @param {string} path - * @param {string} event - * @param {function} callback - * @param {EventSubscriptionSettings} settings - * @returns - */ - this.subscribe = async (path, event, callback, settings) => { - if (!this.settings.network.realtime) { - throw new Error(`Cannot subscribe to realtime events because it has been disabled in the network settings`); - } - let pathSubs = subscriptions[path]; - if (!pathSubs) { pathSubs = subscriptions[path] = []; } - let serverAlreadyNotifying = pathSubs.some(sub => sub.event === event) - || (event === 'mutated' && Object.keys(subscriptions).some(otherPath => PathInfo.get(otherPath).isAncestorOf(path) && subscriptions[otherPath].some(sub => sub.event === event && sub.state === 'active'))); - const subscr = new EventSubscription(path, event, callback, settings); - // { path, event, callback, settings, added: Date.now(), activate() { this.activated = Date.now() }, activated: null, lastEvent: null, cursor: null }; - pathSubs.push(subscr); - - if (this._cache) { - // Events are also handled by cache db - subscr.cacheCallback = (err, path, newValue, oldValue, context) => subscr.callback(err, path.slice(`${this.dbname}/cache/`.length), newValue, oldValue, context); - this._cache.db.api.subscribe(PathInfo.getChildPath(`${this.dbname}/cache`, path), event, subscr.cacheCallback); - } - - if (serverAlreadyNotifying || !this.isConnected) { - // If we're offline, the event will be subscribed once connected - return; - } - if (event === 'mutated') { - // Unsubscribe from 'mutated' events set on descendant paths of current path - Object.keys(subscriptions) - .filter(otherPath => - PathInfo.get(otherPath).isDescendantOf(path) - && subscriptions[otherPath].some(sub => sub.event === 'mutated') - ) - .map(path => _websocketRequest(this.socket, 'unsubscribe', { path, event: 'mutated' }, accessToken)) - .map(promise => promise.catch(err => console.error(err))) - } - const result = await _websocketRequest(this.socket, 'subscribe', { path, event }, accessToken); - subscr.activate(); - return result; - }; - - this.unsubscribe = (path, event = undefined, callback = undefined) => { - if (!this.settings.network.realtime) { - throw new Error(`Cannot unsubscribe from realtime events because it has been disabled in the network settings`); - } - let pathSubs = subscriptions[path]; - if (!pathSubs) { return Promise.resolve(); } - - const unsubscribeFrom = (subscriptions) => { - subscriptions.forEach(subscr => { - pathSubs.splice(pathSubs.indexOf(subscr), 1); - if (this._cache) { - // Events are also handled by cache db, also remove those - console.assert(typeof subscr.cacheCallback !== 'undefined', 'When subscription was added, cacheCallback must have been set'); - this._cache.db.api.unsubscribe(PathInfo.getChildPath(`${this.dbname}/cache`, path), subscr.event, subscr.cacheCallback); - } - }); - }; - - const hadMutatedEvents = pathSubs.some(sub => sub.event === 'mutated'); - if (!event) { - // Unsubscribe from all events on path - unsubscribeFrom(pathSubs); - } - else if (!callback) { - // Unsubscribe from specific event on path - const subscriptions = pathSubs.filter(subscr => subscr.event === event); - unsubscribeFrom(subscriptions); - } - else { - // Unsubscribe from a specific callback on path event - const subscriptions = pathSubs.filter(subscr => subscr.event === event && subscr.callback === callback); - unsubscribeFrom(subscriptions); - } - const hasMutatedEvents = pathSubs.some(sub => sub.event === 'mutated'); - - let promise = Promise.resolve(); - if (pathSubs.length === 0) { - // Unsubscribed from all events on path - delete subscriptions[path]; - if (this.isConnected) { - promise = _websocketRequest(this.socket, 'unsubscribe', { path, access_token: accessToken }, accessToken) - .catch(err => this.debug.error(`Failed to unsubscribe from event(s) on "${path}": ${err.message}`)); + return new Promise<{ cursor?: string }>(async (resolve, reject) => { + let cacheUpdateVal: any; + const handleSuccess = async (context: any) => { + if (this.hasCache && typeof cacheUpdateVal !== 'undefined') { + // Update cache db value + await this.cache.db.api.set(cachePath, cacheUpdateVal); } - } - else if (this.isConnected && !pathSubs.some(subscr => subscr.event === event)) { - // No callbacks left for specific event - promise = _websocketRequest(this.socket, 'unsubscribe', { path: path, event, access_token: accessToken }, accessToken) - .catch(err => this.debug.error(`Failed to unsubscribe from event "${event}" on "${path}": ${err.message}`)); - } - if (this.isConnected && hadMutatedEvents && !hasMutatedEvents) { - // If any descendant paths have mutated events, resubscribe those - const promises = Object.keys(subscriptions) - .filter(otherPath => PathInfo.get(otherPath).isDescendantOf(path) && subscriptions[otherPath].some(sub => sub.event === 'mutated')) - .map(path => _websocketRequest(this.socket, 'subscribe', { path: path, event: 'mutated' }, accessToken)) - .map(promise => promise.catch(err => this.debug.error(`Failed to subscribe to event "${event}" on path "${path}": ${err.message}`))); - promise = Promise.all([promise, ...promises]); - } - return promise; - }; - - this.transaction = (path, callback, options = { context: {} }) => { - const id = ID.generate(); - options.context = options.context || {}; - // TODO: reduce this contextual overhead to 'client_id' only, or additional debugging info upon request - options.context.acebase_mutation = { - client_id: this._id, - id, - op: 'transaction', - path, - flow: 'server' + resolve({ cursor: context?.acebase_cursor as string }); }; - const cachePath = PathInfo.getChildPath(`${this.dbname}/cache`, path); - - return new Promise(async (resolve, reject) => { - let cacheUpdateVal; - const handleSuccess = async (context) => { - if (this._cache && typeof cacheUpdateVal !== 'undefined') { - // Update cache db value - await this._cache.db.api.set(cachePath, cacheUpdateVal); - } - resolve({ cursor: context && context.acebase_cursor }); - }; - if (this.isConnected && settings.network.realtime) { - // Use websocket connection - const startedCallback = async (data) => { - if (data.id === id) { - this.socket.off("tx_started", startedCallback); - const currentValue = Transport.deserialize(data.value); - let newValue = callback(currentValue); - if (newValue instanceof Promise) { - newValue = await newValue; - } - this.socket.emit("transaction", { action: "finish", id: id, path, value: Transport.serialize(newValue), access_token: accessToken }); - if (this._cache) { - cacheUpdateVal = newValue; - } - } - }; - const completedCallback = (data) => { - if (data.id === id) { - this.socket.off("tx_completed", completedCallback); - this.socket.off("tx_error", errorCallback); - handleSuccess(data.context); - } - }; - const errorCallback = data => { - if (data.id === id) { - this.socket.off("tx_started", startedCallback); - this.socket.off("tx_completed", completedCallback); - this.socket.off("tx_error", errorCallback); - reject(new Error(data.reason)); - } - }; - this.socket.on("tx_started", startedCallback); - this.socket.on("tx_completed", completedCallback); - this.socket.on("tx_error", errorCallback); - // TODO: socket.on('disconnect', disconnectedCallback); - this.socket.emit("transaction", { action: "start", id, path, access_token: accessToken, context: options.context }); - } - else { - // Websocket not connected. Try http call instead - const startData = JSON.stringify({ path }); - try { - const tx = await this._request({ ignoreConnectionState: true, method: 'POST', url: `${this.url}/transaction/${this.dbname}/start`, data: startData, context: options.context }); - const id = tx.id; - const currentValue = Transport.deserialize(tx.value); + if (this.isConnected && this.settings.network?.realtime) { + // Use websocket connection + const socket = this.socket as IOWebSocket; + const startedCallback = async (data: { id: string; value: any }) => { + if (data.id === id) { + socket.off('tx_started', startedCallback); + const currentValue = Transport.deserialize(data.value); let newValue = callback(currentValue); if (newValue instanceof Promise) { newValue = await newValue; } - if (this._cache) { + socket.emit('transaction', { action: 'finish', id: id, path, value: Transport.serialize(newValue), access_token: this.accessToken }); + if (this.hasCache) { cacheUpdateVal = newValue; } - const finishData = JSON.stringify({ id, value: Transport.serialize(newValue) }); - const { context } = await this._request({ ignoreConnectionState: true, method: 'POST', url: `${this.url}/transaction/${this.dbname}/finish`, data: finishData, context: options.context, includeContext: true }); - await handleSuccess(context); } - catch (err) { - if (['ETIMEDOUT','ENOTFOUND','ECONNRESET','ECONNREFUSED','EPIPE', 'fetch_failed'].includes(err.code)) { - err.message = NOT_CONNECTED_ERROR_MESSAGE; - } - reject(err); + }; + const completedCallback = (data: { id: string; context: any }) => { + if (data.id === id) { + socket.off('tx_completed', completedCallback); + socket.off('tx_error', errorCallback); + handleSuccess(data.context); } - } - }); - }; - - /** - * @param {object} options - * @param {string} options.url - * @param {'GET'|'PUT'|'POST'|'DELETE'} [options.method='GET'] - * @param {any} [options.data] Data to post when method is PUT or POST - * @param {any} [options.context] Context to add to PUT or POST requests - * @param {(chunk: string) => void} [options.dataReceivedCallback] A method that overrides the default data receiving handler. Override for streaming. - * @param {(length: number) => string|ArrayBufferView|Promise} [options.dataRequestCallback] A method that overrides the default data send handler. Override for streaming. - * @param {boolean} [options.ignoreConnectionState=false] Whether to try the request even if there is no connection - * @param {boolean} [options.includeContext=false] NEW Whether the returned object should contain an optionally returned context object. - * @returns {Promise} returns a promise that resolves with the returned data, or (when options.includeContext === true) an object containing data and returned context - */ - this._request = async (options) => { - if (this.isConnected || options.ignoreConnectionState === true) { - const result = await (async () => { - try { - return await _request(options.method || 'GET', options.url, { data: options.data, accessToken, dataReceivedCallback: options.dataReceivedCallback, dataRequestCallback: options.dataRequestCallback, context: options.context }); + }; + const errorCallback = (data: { id: string; reason: string }) => { + if (data.id === id) { + socket.off('tx_started', startedCallback); + socket.off('tx_completed', completedCallback); + socket.off('tx_error', errorCallback); + reject(new Error(data.reason)); } - catch (err) { - if (this.isConnected && err.isNetworkError) { - // This is a network error, but the websocket thinks we are still connected. - this.debug.warn(`A network error occurred loading ${options.url}`); - - // Start reconnection flow - this._handleDetectedDisconnect(); - } - - // Rethrow the error - throw err; - } - })(); - if (result.context && result.context.acebase_cursor) { - this._updateCursor(result.context.acebase_cursor); - } - if (options.includeContext === true) { - if (!result.context) { result.context = {}; } - return result; - } - else { - return result.data; - } + }; + socket.on('tx_started', startedCallback); + socket.on('tx_completed', completedCallback); + socket.on('tx_error', errorCallback); + // TODO: socket.on('disconnect', disconnectedCallback); + socket.emit('transaction', { action: 'start', id, path, access_token: this.accessToken, context: options.context }); } else { - // We're not connected. We can wait for the connection to be established, - // or fail the request now. Because we have now implemented caching, live requests - // are only executed if they are not allowed to use cached responses. Wait for a - // connection to be established (max 1s), then retry or fail - - if (!this.isConnecting || !settings.network.realtime) { - // We're currently not trying to connect, or not using websocket connection (normal connection logic is still used). - // Fail now - throw new Error(NOT_CONNECTED_ERROR_MESSAGE); + // Websocket not connected. Try http call instead + const startData = JSON.stringify({ path }); + try { + const tx = await this._request({ ignoreConnectionState: true, method: 'POST', url: `${this.url}/transaction/${this.dbname}/start`, data: startData, context: options.context }); + const id = tx.id; + const currentValue = Transport.deserialize(tx.value); + let newValue = callback(currentValue); + if (newValue instanceof Promise) { + newValue = await newValue; + } + if (this.hasCache) { + cacheUpdateVal = newValue; + } + const finishData = JSON.stringify({ id, value: Transport.serialize(newValue) }); + const { context } = await this._request({ ignoreConnectionState: true, method: 'POST', url: `${this.url}/transaction/${this.dbname}/finish`, data: finishData, context: options.context, includeContext: true }); + await handleSuccess(context); + } + catch (err: any) { + if (['ETIMEDOUT','ENOTFOUND','ECONNRESET','ECONNREFUSED','EPIPE', 'fetch_failed'].includes(err.code)) { + err.message = NOT_CONNECTED_ERROR_MESSAGE; + } + reject(err); } - - const connectPromise = new Promise(resolve => this.socket.once('connect', resolve)); - await promiseTimeout(connectPromise, 1000, 'Waiting for connection').catch(err => { - throw new Error(NOT_CONNECTED_ERROR_MESSAGE); - }); - return this._request(options); // Retry } - }; + }); + } - const handleSignInResult = (result, emitEvent = true) => { - this._eventTimeline.signIn = Date.now(); - const details = { user: result.user, accessToken: result.access_token, provider: result.provider || 'acebase' }; - accessToken = details.accessToken; - this.socket & this.socket.emit('signin', details.accessToken); // Make sure the connected websocket server knows who we are as well. - emitEvent && eventCallback('signin', details); - return details; + /** + * @returns returns a promise that resolves with the returned data, or (when options.includeContext === true) an object containing data and returned context + */ + private async _request(options: { + url: string; + /** + * @default 'GET' + */ + method?: 'GET' | 'PUT' | 'POST' | 'DELETE'; + /** + * Data to post when method is PUT or POST + */ + data?: any; + /** + * Context to add to PUT or POST requests + */ + context?: any; + /** + * A method that overrides the default data receiving handler. Override for streaming. + */ + dataReceivedCallback?: (chunk: string) => void; + /** + * A method that overrides the default data send handler. Override for streaming. + */ + dataRequestCallback?: (length: number) => string | ArrayBufferView | Promise; + /** + * Whether to try the request even if there is no connection + * @default false + */ + ignoreConnectionState?: boolean; + /** + * NEW Whether the returned object should contain an optionally returned context object. + * @default false + */ + includeContext?: boolean; + }): Promise { + if (this.isConnected || options.ignoreConnectionState === true) { + const result = await (async () => { + try { + return await _request(options.method || 'GET', options.url, { data: options.data, accessToken: this.accessToken, dataReceivedCallback: options.dataReceivedCallback, dataRequestCallback: options.dataRequestCallback, context: options.context }); + } + catch (err: any) { + if (this.isConnected && err.isNetworkError) { + // This is a network error, but the websocket thinks we are still connected. + this.debug.warn(`A network error occurred loading ${options.url}`); + + // Start reconnection flow + this._handleDetectedDisconnect(err); + } + + // Rethrow the error + throw err; + } + })(); + if (result.context && result.context.acebase_cursor) { + this._updateCursor(result.context.acebase_cursor); + } + if (options.includeContext === true) { + if (!result.context) { result.context = {}; } + return result; + } + else { + return result.data; + } } + else { + // We're not connected. We can wait for the connection to be established, + // or fail the request now. Because we have now implemented caching, live requests + // are only executed if they are not allowed to use cached responses. Wait for a + // connection to be established (max 1s), then retry or fail - this.signIn = async (username, password) => { - if (!this.isConnected) { throw new Error(NOT_CONNECTED_ERROR_MESSAGE); } - const result = await this._request({ method: 'POST', url: `${this.url}/auth/${this.dbname}/signin`, data: { method: 'account', username, password, client_id: this.socket && this.socket.id } }); - return handleSignInResult(result); - }; - - this.signInWithEmail = async (email, password) => { - if (!this.isConnected) { throw new Error(NOT_CONNECTED_ERROR_MESSAGE); } - const result = await this._request({ method: 'POST', url: `${this.url}/auth/${this.dbname}/signin`, data: { method: 'email', email, password, client_id: this.socket && this.socket.id } }); - return handleSignInResult(result); - }; - - this.signInWithToken = async (token, emitEvent = true) => { - if (!this.isConnected) { - throw new Error('Cannot sign in because client is not connected to the server. If you want to automatically sign in the user with this access token once a connection is established, use client.auth.setAccessToken(token)'); + if (!this.isConnecting || !this.settings.network?.realtime) { + // We're currently not trying to connect, or not using websocket connection (normal connection logic is still used). + // Fail now + throw new Error(NOT_CONNECTED_ERROR_MESSAGE); } - const result = await this._request({ method: 'POST', url: `${this.url}/auth/${this.dbname}/signin`, data: { method: 'token', access_token: token, client_id: this.socket && this.socket.id } }); - return handleSignInResult(result, emitEvent); - }; - this.setAccessToken = (token) => { - accessToken = token; - }; + const connectPromise = new Promise(resolve => this.socket?.once('connect', resolve)); + await promiseTimeout(connectPromise, 1000, 'Waiting for connection').catch(err => { + throw new Error(NOT_CONNECTED_ERROR_MESSAGE); + }); + return this._request(options); // Retry + } + } - this.startAuthProviderSignIn = async (providerName, callbackUrl, options) => { - if (!this.isConnected) { throw new Error(NOT_CONNECTED_ERROR_MESSAGE); } - const optionParams = typeof options === 'object' - ? '&' + Object.keys(options).map(key => `option_${key}=${encodeURIComponent(options[key])}`).join('&') - : ''; - const result = await this._request({ url: `${this.url}/oauth2/${this.dbname}/init?provider=${providerName}&callbackUrl=${callbackUrl}${optionParams}` }); - return { redirectUrl: result.redirectUrl }; - }; + private handleSignInResult(result: { + user: AceBaseUser; + access_token: string; + provider: { + name: string; + access_token: string; + refresh_token: string; + expires_in: number; + }, + }, emitEvent = true) { + this._eventTimeline.signIn = Date.now(); + const details = { user: result.user, accessToken: result.access_token, provider: result.provider || 'acebase' }; + this.accessToken = details.accessToken; + this.socket?.emit('signin', details.accessToken); // Make sure the connected websocket server knows who we are as well. + emitEvent && this.eventCallback('signin', details); + return details; + } - this.finishAuthProviderSignIn = async (callbackResult) => { - /** @type {{ provider: { name: string, access_token: string, refresh_token: string, expires_in: number }, access_token: string, user?: AceBaseUser }} */ - let result; - try { - result = JSON.parse(Base64.decode(callbackResult)); + public async signIn(username: string, password: string) { + if (!this.isConnected) { throw new Error(NOT_CONNECTED_ERROR_MESSAGE); } + const result = await this._request({ method: 'POST', url: `${this.url}/auth/${this.dbname}/signin`, data: { method: 'account', username, password, client_id: this.socket && this.socket.id } }); + return this.handleSignInResult(result); + } + + public async signInWithEmail(email: string, password: string) { + if (!this.isConnected) { throw new Error(NOT_CONNECTED_ERROR_MESSAGE); } + const result = await this._request({ method: 'POST', url: `${this.url}/auth/${this.dbname}/signin`, data: { method: 'email', email, password, client_id: this.socket && this.socket.id } }); + return this.handleSignInResult(result); + } + + public async signInWithToken(token: string, emitEvent = true){ + if (!this.isConnected) { + throw new Error('Cannot sign in because client is not connected to the server. If you want to automatically sign in the user with this access token once a connection is established, use client.auth.setAccessToken(token)'); + } + const result = await this._request({ method: 'POST', url: `${this.url}/auth/${this.dbname}/signin`, data: { method: 'token', access_token: token, client_id: this.socket && this.socket.id } }); + return this.handleSignInResult(result, emitEvent); + } + + public setAccessToken(token: string) { + this.accessToken = token; + } + + public async startAuthProviderSignIn(providerName: string, callbackUrl: string, options: any) { + if (!this.isConnected) { throw new Error(NOT_CONNECTED_ERROR_MESSAGE); } + const optionParams = typeof options === 'object' + ? '&' + Object.keys(options).map(key => `option_${key}=${encodeURIComponent(options[key])}`).join('&') + : ''; + const result = await this._request({ url: `${this.url}/oauth2/${this.dbname}/init?provider=${providerName}&callbackUrl=${callbackUrl}${optionParams}` }); + return { redirectUrl: result.redirectUrl }; + } + + public async finishAuthProviderSignIn(callbackResult: string) { + let result: { provider: { name: string, access_token: string, refresh_token: string, expires_in: number }, access_token: string, user: AceBaseUser }; + try { + result = JSON.parse(Base64.decode(callbackResult)); + } + catch (err) { + throw new Error(`Invalid result`); + } + if (!result.user) { + // AceBaseServer 1.9.0+ does not include user details in the redirect. + // We must get (and validate) auth state with received access token + this.accessToken = result.access_token; + const authState = await this._request({ url: `${this.url}/auth/${this.dbname}/state` }); + if (!authState.signed_in) { + this.accessToken = null; + throw new Error(`Invalid access token received: not signed in`); } - catch (err) { - throw new Error(`Invalid result`); - } - if (!result.user) { - // AceBaseServer 1.9.0+ does not include user details in the redirect. - // We must get (and validate) auth state with received access token - accessToken = result.access_token; - const authState = await this._request({ url: `${this.url}/auth/${this.dbname}/state` }); - if (!authState.signed_in) { - accessToken = null; - throw new Error(`Invalid access token received: not signed in`); - } - result.user = authState.user; - } - return handleSignInResult(result); - }; + result.user = authState.user; + } + return this.handleSignInResult(result); + } - this.refreshAuthProviderToken = async (providerName, refreshToken) => { - if (!this.isConnected) { throw new Error(NOT_CONNECTED_ERROR_MESSAGE); } - return this._request({ url: `${this.url}/oauth2/${this.dbname}/refresh?provider=${providerName}&refresh_token=${refreshToken}` }); - }; + async refreshAuthProviderToken(providerName: string, refreshToken: string) { + if (!this.isConnected) { throw new Error(NOT_CONNECTED_ERROR_MESSAGE); } + const result = await this._request({ url: `${this.url}/oauth2/${this.dbname}/refresh?provider=${providerName}&refresh_token=${refreshToken}` }); + return result as { provider: IAceBaseAuthProviderTokens }; + } - this.signOut = async (options = { everywhere: false, clearCache: false }) => { - if (typeof options === 'boolean') { - // Old signature signOut(everywhere:boolean = false) - options = { everywhere: options }; - } - else if (typeof options !== 'object') { - throw new TypeError('options must be an object'); - } - if (typeof options.everywhere !== 'boolean') { options.everywhere = false; } - if (typeof options.clearCache !== 'boolean') { options.clearCache = false; } + async signOut(options: boolean | { + everywhere?: boolean; + clearCache?: boolean; + } = { + everywhere: false, + clearCache: false, + }) { + if (typeof options === 'boolean') { + // Old signature signOut(everywhere:boolean = false) + options = { everywhere: options }; + } + else if (typeof options !== 'object') { + throw new TypeError('options must be an object'); + } + if (typeof options.everywhere !== 'boolean') { options.everywhere = false; } + if (typeof options.clearCache !== 'boolean') { options.clearCache = false; } - if (!accessToken) { return; } - if (!this.isConnected) { throw new Error(NOT_CONNECTED_ERROR_MESSAGE); } - const result = await this._request({ method: 'POST', url: `${this.url}/auth/${this.dbname}/signout`, data: { client_id: this.socket && this.socket.id, everywhere: options.everywhere } }); - this.socket && this.socket.emit('signout', accessToken); // Make sure the connected websocket server knows we signed out as well. - accessToken = null; - if (this._cache && options.clearCache) { - // Clear cache, but don't wait for it to finish - this.clearCache().catch(err => { - console.error(`Could not clear cache:`, err); - }); - } - eventCallback('signout'); - }; + if (!this.accessToken) { return; } + if (!this.isConnected) { throw new Error(NOT_CONNECTED_ERROR_MESSAGE); } + const result = await this._request({ method: 'POST', url: `${this.url}/auth/${this.dbname}/signout`, data: { client_id: this.socket && this.socket.id, everywhere: options.everywhere } }); + this.socket && this.socket.emit('signout', this.accessToken); // Make sure the connected websocket server knows we signed out as well. + this.accessToken = null; + if (this.hasCache && options.clearCache) { + // Clear cache, but don't wait for it to finish + this.clearCache().catch(err => { + console.error(`Could not clear cache:`, err); + }); + } + this.eventCallback('signout'); + } - this.changePassword = async (uid, currentPassword, newPassword) => { - if (!accessToken) { throw new Error(`not_signed_in`); } - if (!this.isConnected) { throw new Error(NOT_CONNECTED_ERROR_MESSAGE); } - const result = await this._request({ method: 'POST', url: `${this.url}/auth/${this.dbname}/change_password`, data: { uid, password: currentPassword, new_password: newPassword } }); - accessToken = result.access_token; - return { accessToken }; - }; - - this.forgotPassword = async (email) => { - if (!this.isConnected) { throw new Error(NOT_CONNECTED_ERROR_MESSAGE); } - const result = await this._request({ method: 'POST', url: `${this.url}/auth/${this.dbname}/forgot_password`, data: { email } }); - return result; - }; + async changePassword(uid: string, currentPassword: string, newPassword: string) { + if (!this.accessToken) { throw new Error(`not_signed_in`); } + if (!this.isConnected) { throw new Error(NOT_CONNECTED_ERROR_MESSAGE); } + const result = await this._request({ method: 'POST', url: `${this.url}/auth/${this.dbname}/change_password`, data: { uid, password: currentPassword, new_password: newPassword } }); + this.accessToken = result.access_token; + return { accessToken: this.accessToken as string }; + } - this.verifyEmailAddress = async (verificationCode) => { - if (!this.isConnected) { throw new Error(NOT_CONNECTED_ERROR_MESSAGE); } - const result = await this._request({ method: 'POST', url: `${this.url}/auth/${this.dbname}/verify_email`, data: { code: verificationCode } }); - return result; - }; + async forgotPassword(email: string) { + if (!this.isConnected) { throw new Error(NOT_CONNECTED_ERROR_MESSAGE); } + const result = await this._request({ method: 'POST', url: `${this.url}/auth/${this.dbname}/forgot_password`, data: { email } }); + return result; + } - this.resetPassword = async (resetCode, newPassword) => { - if (!this.isConnected) { throw new Error(NOT_CONNECTED_ERROR_MESSAGE); } - const result = await this._request({ method: 'POST', url: `${this.url}/auth/${this.dbname}/reset_password`, data: { code: resetCode, password: newPassword } }) - return result; - }; + async verifyEmailAddress(verificationCode: string) { + if (!this.isConnected) { throw new Error(NOT_CONNECTED_ERROR_MESSAGE); } + const result = await this._request({ method: 'POST', url: `${this.url}/auth/${this.dbname}/verify_email`, data: { code: verificationCode } }); + return result; + } - this.signUp = async (details, signIn = true) => { - if (!this.isConnected) { throw new Error(NOT_CONNECTED_ERROR_MESSAGE); } - const result = await this._request({ method: 'POST', url: `${this.url}/auth/${this.dbname}/signup`, data: details }); - if (signIn) { - return handleSignInResult(result); - } - return { user: result.user, accessToken }; - }; + async resetPassword(resetCode: string, newPassword: string) { + if (!this.isConnected) { throw new Error(NOT_CONNECTED_ERROR_MESSAGE); } + const result = await this._request({ method: 'POST', url: `${this.url}/auth/${this.dbname}/reset_password`, data: { code: resetCode, password: newPassword } }); + return result; + } - this.updateUserDetails = async (details) => { - if (!this.isConnected) { throw new Error(NOT_CONNECTED_ERROR_MESSAGE); } - const result = await this._request({ method: 'POST', url: `${this.url}/auth/${this.dbname}/update`, data: details }); - return { user: result.user }; - }; + async signUp(details: any, signIn = true) { + if (!this.isConnected) { throw new Error(NOT_CONNECTED_ERROR_MESSAGE); } + const result = await this._request({ method: 'POST', url: `${this.url}/auth/${this.dbname}/signup`, data: details }); + if (signIn) { + return this.handleSignInResult(result); + } + return { user: result.user, accessToken: this.accessToken }; + } - this.deleteAccount = async (uid, signOut = true) => { - if (!this.isConnected) { throw new Error(NOT_CONNECTED_ERROR_MESSAGE); } - const result = await this._request({ method: 'POST', url: `${this.url}/auth/${this.dbname}/delete`, data: { uid } }); - if (signOut) { - this.socket && this.socket.emit('signout', accessToken); - accessToken = null; - eventCallback('signout'); - } - return true; - }; + async updateUserDetails(details: any) { + if (!this.isConnected) { throw new Error(NOT_CONNECTED_ERROR_MESSAGE); } + const result = await this._request({ method: 'POST', url: `${this.url}/auth/${this.dbname}/update`, data: details }); + return { user: result.user }; + } + + async deleteAccount(uid: string, signOut = true) { + if (!this.isConnected) { throw new Error(NOT_CONNECTED_ERROR_MESSAGE); } + const result = await this._request({ method: 'POST', url: `${this.url}/auth/${this.dbname}/delete`, data: { uid } }); + if (signOut) { + this.socket && this.socket.emit('signout', this.accessToken); + this.accessToken = null; + this.eventCallback('signout'); + } + return true; } get isConnected() { @@ -978,27 +1065,37 @@ class WebApi extends Api { return this._request({ url: `${this.url}/stats/${this.dbname}` }); } - async sync(options = { firstSync: false, fetchFreshData: true, eventCallback: null }) { + async sync(options: { + firstSync?: boolean; + fetchFreshData?: boolean; + /** TODO: fire events on instance instead of custom callback? */ + eventCallback?: ((event: string, data?: any) => void) | null; + } = { + firstSync: false, + fetchFreshData: true, + eventCallback: null, + }) { // Sync cache if (!this.isConnected) { throw new Error(NOT_CONNECTED_ERROR_MESSAGE); } - if (this._cache && !this._cache.db.isReady) { + if (this.hasCache && !this.cache.db.isReady) { throw new Error(`cache database is not ready yet`); } this._eventTimeline.sync = Date.now(); - options.eventCallback && options.eventCallback('sync_start'); - const handleStatsUpdateError = err => { + options.eventCallback?.('sync_start'); + const handleStatsUpdateError = (err: any) => { this.debug.error(`Failed to update cache db stats:`, err); }; try { let totalPendingChanges = 0; - const useCursor = this.settings.sync.useCursor !== false; + const useCursor = this.settings.sync?.useCursor !== false; const cursor = useCursor ? this._cursor.sync : null; - const cacheApi = this._cache && this._cache.db.api; - if (this._cache) { + if (this.hasCache) { // Part 1: PUSH local changes - const { value: pendingChanges, context } = await cacheApi.get(`${this.dbname}/pending`); + const cacheApi = this.cache.db.api; + const { value, context } = await cacheApi.get(`${this.dbname}/pending`); + const pendingChanges = value as { [id: string]: PendingMutation }; cacheApi.set(`${this.dbname}/stats/last_sync_start`, new Date()).catch(handleStatsUpdateError); try { // Merge mutations to multiple properties into single updates (again) @@ -1011,30 +1108,30 @@ class WebApi extends Api { // --> should merge into: // 1. update users/ewout: { name: 'Ewout', address: { street: 'My street', nr: 1 }, age: null } // 4. remove users/ewout/address/street (does not attempt to merge with nested properties of previous updates) - + type PendingMutationWithIds = PendingMutation & { ids: string[] }; const ids = Object.keys(pendingChanges || {}).sort(); // sort a-z, process oldest mutation first const compatibilityMode = ids.map(id => pendingChanges[id]).some(m => m.type === 'update'); const mutations = compatibilityMode ? ids.map(id => { // If any "update" mutations are in the db, these are old mutations. Process them unaltered. This is for backward compatibility only, can be removed later. (if code was able to update, mutations could have already been synced too, right?) - const mutation = pendingChanges[id]; - mutation.id = id; + const mutation = pendingChanges[id] as PendingMutationWithIds; + mutation.ids = [id]; return mutation; }) : ids.reduce((mutations, id) => { - const change = pendingChanges[id]; + const change = pendingChanges[id] as PendingMutationWithIds; console.assert(['set', 'remove'].includes(change.type), 'Only "set" and "remove" mutations should be present'); if (change.path === '') { // 'set' on the root path - can't turn this into an update on the parent. - // With new approach, there should be no previous 'set' or 'remove' mutation on any node because they - // have been removed by _addCacheSetMutation. But... if there are old mutations in the db + // With new approach, there should be no previous 'set' or 'remove' mutation on any node because they + // have been removed by _addCacheSetMutation. But... if there are old mutations in the db // without 'update' mutations (because then we'd have been in compatibilityMode above) - we'll filter // them out here. In the future we could just add this change without checking, but code below doesn't // harm the process, so it's ok to stay. const rootUpdate = mutations.find(u => u.path === ''); if (rootUpdate) { rootUpdate.data = change.data; } - else { change.id = id; mutations.push(change); } + else { change.ids = [id]; mutations.push(change); } } else { const pathInfo = PathInfo.get(change.path); @@ -1053,9 +1150,9 @@ class WebApi extends Api { } } return mutations; - }, []); + }, [] as PendingMutationWithIds[]); - for (let m of mutations) { + for (const m of mutations) { const ids = m.ids; this.debug.verbose(`SYNC pushing mutations ${ids.join(',')}: `, m); totalPendingChanges++; @@ -1076,10 +1173,10 @@ class WebApi extends Api { } this.debug.verbose(`SYNC mutation ${ids.join(',')} processed ok`); - const updates = ids.reduce((updates, id) => (updates[id] = null, updates), {}); + const updates = ids.reduce((updates, id) => (updates[id] = null, updates), {} as Record); cacheApi.update(`${this.dbname}/pending`, updates); // delete from cache db } - catch(err) { + catch (err: any) { // Updating remote db failed this.debug.error(`SYNC mutations ${ids.join(',')} failed: ${err.message}`); if (!this.isConnected) { @@ -1087,8 +1184,8 @@ class WebApi extends Api { throw err; } // We are connected, so the mutation is not allowed or otherwise denied. - if (typeof err === 'string') { - err = { code: 'unknown', message: err, stack: 'n/a' }; + if (typeof err === 'string') { + err = { code: 'unknown', message: err, stack: 'n/a' }; } // Store error report @@ -1097,9 +1194,9 @@ class WebApi extends Api { cacheApi.transaction(`${this.dbname}/pending/${id}`, m => { if (!m.error) { m.error = { - first: errorReport, + first: errorReport, last: errorReport, - retries: 0 + retries: 0, }; } else { @@ -1119,7 +1216,7 @@ class WebApi extends Api { // TODO: Send error report to server? // this.reportError({ code: 'sync-mutation', report: errorReport }); - options.eventCallback && options.eventCallback('sync_change_error', { error: err, change: m }); + options.eventCallback?.('sync_change_error', { error: err, change: m }); } } @@ -1128,11 +1225,11 @@ class WebApi extends Api { // Update stats cacheApi.set(`${this.dbname}/stats/last_sync_end`, new Date()).catch(handleStatsUpdateError); } - catch(err) { + catch (err: any) { // 1 or more pending changes could not be processed. this.debug.error(`SYNC push error: ${err.message}`); - if (typeof err === 'string') { - err = { code: 'unknown', message: err, stack: 'n/a' }; + if (typeof err === 'string') { + err = { code: 'unknown', message: err, stack: 'n/a' }; } cacheApi.set(`${this.dbname}/stats/last_sync_error`, { date: new Date(), code: err.code || 'unknown', message: err.message, stack: err.stack }).catch(handleStatsUpdateError); throw err; @@ -1142,11 +1239,11 @@ class WebApi extends Api { // We've pushed our changes, now get fresh data for all paths with active subscriptions // Using a cursor we can get changes since disconnect - // - If we have a cursor, we were connected before. + // - If we have a cursor, we were connected before. // - A cursor can only be used for events added while connected, not for events added while offline // - A cursor can currently only be used if a cache db is used. // - If there is no cursor because there were no events fired during a previous connection, use disconnected logic for all events - // + // // ------------------------------------------------------------- // | | event subscribe time | // | event | pre-connect | connected | disconnected | @@ -1167,38 +1264,37 @@ class WebApi extends Api { const subscriptions = subscriptionPaths.reduce((subs, path) => { this._subscriptions[path].forEach(sub => subs.push(sub)); return subs; - }, []); - subscriptions.for = function(path) { - return this.filter(sub => sub.path === path); - }; + }, [] as EventSubscription[]); + const subscriptionsFor = (path: string) => subscriptions.filter(sub => sub.path === path); - if (this._cache) { // && options.fetchFreshData + if (this.hasCache) { // && options.fetchFreshData // Part 2: PULL remote changes / fresh data + const cacheApi = this.cache.db.api; // Attach temp events to cache db so they will fire for data changes (just for counting) subscriptions.forEach(sub => { - sub.tempCallback = (err, path, newValue, oldValue, context) => { + sub.tempCallback = () => { //(err, path, newValue, oldValue, context) => { totalRemoteChanges++; - } + }; cacheApi.subscribe(PathInfo.getChildPath(`${this.dbname}/cache`, sub.path), sub.event, sub.tempCallback); }); - let strategy = { - /** @type {string[]} Data paths to reload */ - reload: [], - /** @type {Array<{path: string, events: string[]}>} Event targets to fetch changes with cursor */ - cursor: [], - /** @type {Array} Subscriptions that have custom sync fallback logic, used when there is no automated way to synchronize */ - fallback: [], - /** @type {Array<{path: string, events: string[]}>} Event targets to warn about */ - warn: [], - /** @type {Array} Subscriptions that require no action because they were added after last connect event */ - noop: [] + const strategy = { + /** Data paths to reload */ + reload: [] as string[], + /** Event targets to fetch changes with cursor */ + cursor: [] as Array<{path: string, events: string[]}>, + /** Subscriptions that have custom sync fallback logic, used when there is no automated way to synchronize */ + fallback: [] as Array, + /** Event targets to warn about */ + warn: [] as Array<{path: string, events: string[]}>, + /** Subscriptions that require no action because they were added after last connect event */ + noop: [] as Array, }; // const wasAddedOffline = sub => { // return sub.lastSynced === 0 && sub.added > this._eventTimeline.disconnect && sub.added < this._eventTimeline.connect; // }; - const hasStaleValue = sub => { + const hasStaleValue = (sub: EventSubscription) => { // -------------------------------- // | cursor | added | stale | // -------------------------------| @@ -1214,11 +1310,11 @@ class WebApi extends Api { if (addedWhileOffline) { return true; } if (addedBeforeDisconnection) { return cursor ? false : true; } return false; - } + }; strategy.reload = subscriptionPaths .filter(path => { if (path.includes('*') || path.includes('$')) { return false; } // Can't load wildcard paths - return subscriptions.for(path).some(sub => { + return subscriptionsFor(path).some(sub => { if (hasStaleValue(sub)) { if (typeof sub.settings.syncFallback === 'function') { return false; } if (sub.settings.syncFallback === 'reload') { return true; } @@ -1231,38 +1327,38 @@ class WebApi extends Api { .reduce((reloadPaths, path) => { !reloadPaths.some(p => p === path || PathInfo.get(p).isAncestorOf(path)) && reloadPaths.push(path); return reloadPaths; - }, []); + }, [] as typeof strategy.reload); strategy.fallback = subscriptionPaths .filter(path => !strategy.reload.some(p => p === path || PathInfo.get(p).isAncestorOf(path))) .reduce((fallbackItems, path) => { - subscriptions.for(path).forEach(sub => { + subscriptionsFor(path).forEach(sub => { if (hasStaleValue(sub) && typeof sub.settings.syncFallback === 'function') { fallbackItems.push(sub); } }); return fallbackItems; - }, []); + }, [] as typeof strategy.fallback); strategy.cursor = !cursor ? [] : subscriptionPaths .filter(path => !strategy.reload.some(p => p === path || PathInfo.get(p).isAncestorOf(path))) .reduce((cursorItems, path) => { - const subs = subscriptions.for(path); + const subs = subscriptionsFor(path); const events = subs.filter(sub => !hasStaleValue(sub) && !strategy.fallback.includes(sub)) - .reduce((events, sub) => (events.includes(sub.event) || events.push(sub.event)) && events, []); + .reduce((events, sub) => (events.includes(sub.event) || events.push(sub.event) as 1) && events, [] as string[]); events.length > 0 && cursorItems.push({ path, events }); return cursorItems; - }, []); + }, [] as typeof strategy.cursor); strategy.warn = subscriptionPaths .filter(path => !strategy.reload.some(p => p === path || PathInfo.get(p).isAncestorOf(path))) .reduce((warnItems, path) => { - const subs = subscriptions.for(path).filter(sub => !strategy.fallback.includes(sub)); + const subs = subscriptionsFor(path).filter(sub => !strategy.fallback.includes(sub)); subs.forEach(sub => { if (typeof sub.settings.syncFallback === 'function' || sub.added > this._eventTimeline.connect) { strategy.noop.push(sub); } else if (!strategy.cursor.some(item => item.path === sub.path && item.events.includes(sub.event))) { - let item = warnItems.find(item => item.path === sub.path); + const item = warnItems.find(item => item.path === sub.path); if (!item) { - warnItems.push({ path: sub.path, events: [sub.event] }) + warnItems.push({ path: sub.path, events: [sub.event] }); } else if (!item.events.includes(sub.event)) { item.events.push(sub.event); @@ -1270,11 +1366,11 @@ class WebApi extends Api { } }); return warnItems; - }, []); + }, [] as typeof strategy.warn); console.log(`SYNC strategy`, strategy); - const syncPromises = []; + const syncPromises = [] as Promise[]; if (strategy.cursor.length > 0) { this.debug.log(`SYNC using cursor "${cursor}" for event(s) ${strategy.cursor.map(item => `${item.events.join(', ')} on "/${item.path}"`).join(', ')}`); const cursorPromise = (async () => { @@ -1284,7 +1380,7 @@ class WebApi extends Api { remoteMutations = result.changes; this._updateCursor(result.new_cursor); } - catch(err) { + catch (err: any) { this.debug.error(`SYNC: Could not load remote changes`, err); options.eventCallback && options.eventCallback('sync_cursor_error', err); if (err.code === 'no_transaction_logging') { @@ -1324,13 +1420,13 @@ class WebApi extends Api { const reloadPromise = (async () => { const promises = strategy.reload.map(path => { this.debug.verbose(`SYNC: load "/${path}"`); - return this.get(path, { allow_cache: false }) - .catch(err => { - this.debug.error(`SYNC: could not load "/${path}"`, err); - options.eventCallback && options.eventCallback('sync_pull_error', err); - }); + return this.get(path, { cache_mode: 'bypass' }) // allow_cache: false + .catch(err => { + this.debug.error(`SYNC: could not load "/${path}"`, err); + options.eventCallback && options.eventCallback('sync_pull_error', err); + }); }); - await Promise.all(promises); + await Promise.all(promises); })(); syncPromises.push(reloadPromise); } @@ -1340,6 +1436,9 @@ class WebApi extends Api { const promises = strategy.fallback.map(async sub => { this.debug.verbose(`SYNC: running fallback for event ${sub.event} on "/${sub.path}"`); try { + if (sub.settings.syncFallback === 'reload') { + throw new Error(`DEV ERROR: Not expecting "reload" as fallback`); + } await sub.settings.syncFallback(); } catch (err) { @@ -1347,7 +1446,7 @@ class WebApi extends Api { options.eventCallback && options.eventCallback('sync_fallback_error', err); } }); - await Promise.all(promises); + await Promise.all(promises); })(); syncPromises.push(fallbackPromise); } @@ -1359,27 +1458,29 @@ class WebApi extends Api { await Promise.all(syncPromises); // Wait shortly to allow any pending temp cache events to fire - await new Promise(resolve => setTimeout(resolve, 10)); + await new Promise(resolve => setTimeout(resolve, 10)); // Unsubscribe temp cache subscriptions subscriptions.forEach(sub => { - console.assert(typeof sub.tempCallback === 'function'); + if (typeof sub.tempCallback !== 'function') { + throw new Error('DEV ERROR: tempCallback must be a function'); + } cacheApi.unsubscribe(PathInfo.getChildPath(`${this.dbname}/cache`, sub.path), sub.event, sub.tempCallback); delete sub.tempCallback; }); } else if (!this._cache) { // Not using cache - const syncPromises = []; - + const syncPromises = [] as Promise[]; + // No cache database used // Until acebase-server supports getting missed events with a cursor (in addition to getting mutations), // there is no way for the client to determine exact data changes at this moment - we have no previous values. // We can only fetch fresh data for 'value' events, run syncFallback functions and warn about all other events subscriptionPaths.forEach(path => { - const subs = subscriptions.for(path); - const warnEvents = []; + const subs = subscriptionsFor(path); + const warnEvents = [] as string[]; subs.filter(sub => sub.event !== 'value').forEach(sub => { if (typeof sub.settings.syncFallback === 'function') { syncPromises.push(sub.settings.syncFallback()); @@ -1387,7 +1488,7 @@ class WebApi extends Api { else { !warnEvents.includes(sub.event) && warnEvents.push(sub.event); } - }) + }); if (warnEvents.length > 0) { this.debug.warn(`Subscriptions ${warnEvents.join(', ')} on path "${path}" might have missed events while offline. Data should be reloaded!`); } @@ -1420,19 +1521,30 @@ class WebApi extends Api { /** * Gets all relevant mutations for specific events on a path and since specified cursor - * @param {object} filter - * @param {string} [filter.path] path to get all mutations for, only used if `for` property isn't used - * @param {Array<{ path: string, events: string[] }>} [filter.for] paths and events to get relevant mutations for - * @param {string} filter.cursor cursor to use - * @param {number} filter.timestamp timestamp to use - * @returns {Promise<{ used_cursor: string, new_cursor: string, mutations: object[] }>} */ - async getMutations(filter) { + async getMutations(filter: { + /** + * path to get all mutations for, only used if `for` property isn't used + */ + path?: string; + /** + * paths and events to get relevant mutations for + */ + for?: Array<{ path: string, events: string[] }>; + /** + * cursor to use + */ + cursor?: string | null; + /** + * timestamp to use + */ + timestamp?: number; + }): Promise<{ used_cursor: string | null, new_cursor: string, mutations: ValueMutation[] }> { if (typeof filter !== 'object') { throw new Error('No filter specified'); } if (typeof filter.cursor !== 'string' && typeof filter.timestamp !== 'number') { throw new Error('No cursor or timestamp given'); } const query = Object.keys(filter) .map(key => { - let val = filter[key]; + let val = filter[key as keyof typeof filter]; if (key === 'for') { val = encodeURIComponent(JSON.stringify(val)); } return typeof val !== 'undefined' ? `${key}=${val}` : null; }) @@ -1440,24 +1552,35 @@ class WebApi extends Api { .join('&'); const { data, context } = await this._request({ url: `${this.url}/sync/mutations/${this.dbname}?${query}`, includeContext: true }); const mutations = Transport.deserialize2(data); - return { used_cursor: filter.cursor, new_cursor: context.acebase_cursor, mutations }; + return { used_cursor: filter.cursor ?? null, new_cursor: context.acebase_cursor, mutations }; } /** * Gets all relevant effective changes for specific events on a path and since specified cursor - * @param {object} filter - * @param {string} [filter.path] path to get all mutations for, only used if `for` property isn't used - * @param {Array<{ path: string, events: string[] }>} [filter.for] paths and events to get relevant mutations for - * @param {string} filter.cursor cursor to use - * @param {number} filter.timestamp timestamp to use - * @returns {Promise<{ used_cursor: string, new_cursor: string, changes: object[] }>} */ - async getChanges(filter) { + async getChanges(filter: { + /** + * path to get changes for, only used if `for` property isn't used + */ + path?: string; + /** + * paths and events to get relevant changes for + */ + for?: Array<{ path: string, events: string[] }>; + /** + * cursor to use + */ + cursor?: string | null; + /** + * timestamp to use + */ + timestamp?: number; + }): Promise<{ used_cursor: string | null, new_cursor: string, changes: ValueChange[] }> { if (typeof filter !== 'object') { throw new Error('No filter specified'); } if (typeof filter.cursor !== 'string' && typeof filter.timestamp !== 'number') { throw new Error('No cursor or timestamp given'); } const query = Object.keys(filter) .map(key => { - let val = filter[key]; + let val = filter[key as keyof typeof filter]; if (key === 'for') { val = encodeURIComponent(JSON.stringify(val)); } return typeof val !== 'undefined' ? `${key}=${val}` : null; }) @@ -1465,22 +1588,32 @@ class WebApi extends Api { .join('&'); const { data, context } = await this._request({ url: `${this.url}/sync/changes/${this.dbname}?${query}`, includeContext: true }); const changes = Transport.deserialize2(data); - return { used_cursor: filter.cursor, new_cursor: context.acebase_cursor, changes }; + return { used_cursor: filter.cursor ?? null, new_cursor: context.acebase_cursor, changes }; } - async _addCacheSetMutation(path, value, context) { + async _addCacheSetMutation(path: string, value: any, context: any) { // Remove all previous mutations on this exact path, and descendants const escapedPath = path.replace(/([.*+?\\$^\(\)\[\]\{\}])/g, '\\$1'); // Replace any character that could cripple the regex. NOTE: nobody should use these characters in their data paths in the first place. const re = new RegExp(`^${escapedPath}(?:\\[|/|$)`); // matches path, path/child, path[0], path[0]/etc, path/child/etc/etc - await this._cache.db.query(`${this.dbname}/pending`) + await this._cache?.db.query(`${this.dbname}/pending`) .filter('path', 'matches', re) .remove(); // Add new mutation - return this._cache.db.api.set(`${this.dbname}/pending/${ID.generate()}`, { type: value !== null ? 'set' : 'remove', path, data: value, context }); + return this._cache?.db.api.set(`${this.dbname}/pending/${ID.generate()}`, { type: value !== null ? 'set' : 'remove', path, data: value, context }); } - set(path, value, options = { allow_cache: true, context: {} }) { + set( + path: string, + value: any, + options: { + allow_cache?: boolean; + context?: any; + } = { + allow_cache: true, + context: {}, + }, + ): Promise<{ cursor?: string; }> { // TODO: refactor allow_cache to cache_mode if (!options.context) { options.context = {}; } const useCache = this._cache && options.allow_cache !== false; @@ -1491,13 +1624,13 @@ class WebApi extends Api { id: ID.generate(), op: 'set', path, - flow: useCache ? useServer ? 'parallel' : 'cache' : 'server' + flow: useCache ? useServer ? 'parallel' : 'cache' : 'server', }; const updateServer = async () => { const data = JSON.stringify(Transport.serialize(value)); - const { context } = await this._request({ method: "PUT", url: `${this.url}/data/${this.dbname}/${path}`, data, context: options.context, includeContext: true }); + const { context } = await this._request({ method: 'PUT', url: `${this.url}/data/${this.dbname}/${path}`, data, context: options.context, includeContext: true }); Object.assign(options.context, context); // Add to request context - const cursor = context && context.acebase_cursor; + const cursor = context?.acebase_cursor as string | undefined; return { cursor }; // And return the cursor }; if (!useCache) { @@ -1505,16 +1638,16 @@ class WebApi extends Api { } const cachePath = PathInfo.getChildPath(`${this.dbname}/cache`, path); - let rollbackValue; + let rollbackValue: any; const updateCache = () => { - return this._cache.db.api.transaction(cachePath, (currentValue) => { + return this.cache.db.api.transaction(cachePath, (currentValue) => { rollbackValue = currentValue; return value; }, { context: options.context }); }; const rollbackCache = async () => { await cachePromise; // Must be ready first before we can rollback to previous value - return this._cache.db.api.set(cachePath, rollbackValue, { context: options.context }); + return this.cache.db.api.set(cachePath, rollbackValue, { context: options.context }); }; const addPendingTransaction = async () => { await this._addCacheSetMutation(path, value, options.context); @@ -1522,50 +1655,50 @@ class WebApi extends Api { const cachePromise = updateCache(); const tryCachePromise = cachePromise - .then(() => ({ success: true })) + .then(() => ({ success: true, error: null })) .catch(err => ({ success: false, error: err })); const serverPromise = !useServer ? null : updateServer(); - const tryServerPromise = !useServer ? null : serverPromise - .then(() => ({ success: true })) + const tryServerPromise = !useServer ? null : (serverPromise as Promise) + .then(() => ({ success: true, error: null })) .catch(err => ({ success: false, error: err })); Promise.all([ tryCachePromise, tryServerPromise ]) - .then(([ cacheResult, serverResult ]) => { - const networkError = serverPromise && !serverResult.success && serverResult.error.isNetworkError === true; - if (serverPromise && !networkError) { - // Server update succeeded, or failed with a non-network related reason + .then(([ cacheResult, serverResult ]) => { + const networkError = serverPromise && !serverResult?.success && serverResult?.error?.isNetworkError === true; + if (serverPromise && !networkError) { + // Server update succeeded, or failed with a non-network related reason - if (serverResult.success) { - // Server update success - if (!cacheResult.success) { - // Cache update failed for some reason? - this.debug.error(`Failed to set cache for "${path}". Error: `, cacheResult.error); + if (serverResult?.success) { + // Server update success + if (!cacheResult.success) { + // Cache update failed for some reason? + this.debug.error(`Failed to set cache for "${path}". Error: `, cacheResult.error); + } + } + else { + // Server update failed (with a non-network related reason) + if (cacheResult.success) { + // Cache update did succeed, rollback to previous value + this.debug.error(`Failed to set server value for "${path}", rolling back cache to previous value. Error:`, serverResult?.error); + rollbackCache().catch(err => { + this.debug.error(`Failed to roll back cache? Error:`, err); + }); + } } } - else { - // Server update failed (with a non-network related reason) - if (cacheResult.success) { - // Cache update did succeed, rollback to previous value - this.debug.error(`Failed to set server value for "${path}", rolling back cache to previous value. Error:`, serverResult.error) + else if (cacheResult.success) { + // Server was not updated (because we're offline, or a network error occurred), cache update was successful. + // Add pending sync action + + addPendingTransaction().catch(err => { + this.debug.error(`Failed to add pending sync action for "${path}", rolling back cache to previous value. Error:`, err); rollbackCache().catch(err => { this.debug.error(`Failed to roll back cache? Error:`, err); }); - } - } - } - else if (cacheResult.success) { - // Server was not updated (because we're offline, or a network error occurred), cache update was successful. - // Add pending sync action - - addPendingTransaction().catch(err => { - this.debug.error(`Failed to add pending sync action for "${path}", rolling back cache to previous value. Error:`, err); - rollbackCache().catch(err => { - this.debug.error(`Failed to roll back cache? Error:`, err); }); - }); - } - }); + } + }); if (!useServer) { // Fixes issue #7 @@ -1573,10 +1706,20 @@ class WebApi extends Api { } // return server promise by default, so caller can handle potential authorization issues - return this._cache.priority === 'cache' ? cachePromise : serverPromise; + return this._cache?.priority === 'cache' ? cachePromise : serverPromise as Promise<{ cursor?: string }>; } - update(path, updates, options = { allow_cache: true, context: {} }) { + update( + path: string, + updates: Record, + options: { + allow_cache?: boolean; + context?: any; + } = { + allow_cache: true, + context: {}, + }, + ): Promise<{ cursor?: string; }> { // TODO: refactor allow_cache to cache_mode const useCache = this._cache && options && options.allow_cache !== false; const useServer = this.isConnected; @@ -1586,35 +1729,33 @@ class WebApi extends Api { id: ID.generate(), op: 'update', path, - flow: useCache ? useServer ? 'parallel' : 'cache' : 'server' + flow: useCache ? useServer ? 'parallel' : 'cache' : 'server', }; const updateServer = async () => { const data = JSON.stringify(Transport.serialize(updates)); const { context } = await this._request({ method: 'POST', url: `${this.url}/data/${this.dbname}/${path}`, data, context: options.context, includeContext: true }); Object.assign(options.context, context); // Add to request context - const cursor = context && context.acebase_cursor; + const cursor = context.acebase_cursor as string | undefined; return { cursor }; // And return the cursor }; if (!useCache) { return updateServer(); } - const cacheApi = this._cache.db.api; + const cacheApi = this._cache?.db.api as Api; const cachePath = PathInfo.getChildPath(`${this.dbname}/cache`, path); - let rollbackUpdates; - const updateCache = () => { + let rollbackUpdates: any; + const updateCache = async () => { const properties = Object.keys(updates); - return cacheApi.get(cachePath, { include: properties }) - .then(result => { - rollbackUpdates = result.value; - properties.forEach(prop => { - if (!(prop in rollbackUpdates) && updates[prop] !== null) { - // Property being updated doesn't exist in current value, set to null - rollbackUpdates[prop] = null; - } - }); - return cacheApi.update(cachePath, updates, { context: options.context }); + const result = await cacheApi.get(cachePath, { include: properties }); + rollbackUpdates = result.value; + properties.forEach(prop => { + if (!(prop in rollbackUpdates) && updates[prop] !== null) { + // Property being updated doesn't exist in current value, set to null + rollbackUpdates[prop] = null; + } }); + return cacheApi.update(cachePath, updates, { context: options.context }); }; const rollbackCache = async () => { await cachePromise; // Must be ready first before we can rollback to previous value @@ -1661,11 +1802,11 @@ class WebApi extends Api { // Create 'set' mutations for all of this 'update's properties const pathInfo = PathInfo.get(path); - const mutations = Object.keys(updates).map(prop => { - if (updates instanceof Array) { prop = parseInt(prop); } + const mutations = Object.keys(updates).map((prop: string | number) => { + if (updates instanceof Array) { prop = parseInt(prop as string); } return { path: pathInfo.childPath(prop), - value: updates[prop] + value: updates[prop], }; }); @@ -1676,50 +1817,50 @@ class WebApi extends Api { const cachePromise = updateCache(); const tryCachePromise = cachePromise - .then(() => ({ success: true })) + .then(() => ({ success: true, error: null })) .catch(err => ({ success: false, error: err })); const serverPromise = !useServer ? null : updateServer(); - const tryServerPromise = !useServer ? null : serverPromise - .then(() => ({ success: true })) - .catch(err => ({ success: false, error: err })); + const tryServerPromise = !useServer ? { executed: false, success: false, error: null } : (serverPromise as Promise) + .then(() => ({ executed: true, success: true, error: null })) + .catch(err => ({ executed: true, success: false, error: err })); Promise.all([ tryCachePromise, tryServerPromise ]) - .then(([ cacheResult, serverResult ]) => { - const networkError = serverPromise && !serverResult.success && serverResult.error.isNetworkError === true; - if (serverPromise && !networkError) { - // Server update succeeded, or failed with a non-network related reason + .then(([ cacheResult, serverResult ]) => { + const networkError = serverResult.executed && !serverResult.success && serverResult.error.isNetworkError === true; + if (serverResult.executed && !networkError) { + // Server update succeeded, or failed with a non-network related reason - if (serverResult.success) { - // Server update success - if (!cacheResult.success) { - // Cache update failed for some reason? - this.debug.error(`Failed to update cache for "${path}". Error: `, cacheResult.error); + if (serverResult.success) { + // Server update success + if (!cacheResult.success) { + // Cache update failed for some reason? + this.debug.error(`Failed to update cache for "${path}". Error: `, cacheResult.error); + } + } + else { + // Server update failed + if (cacheResult.success) { + // Cache update did succeed, rollback to previous value + this.debug.error(`Failed to update server value for "${path}", rolling back cache to previous value. Error:`, serverResult.error); + rollbackCache().catch(err => { + this.debug.error(`Failed to roll back cache? Error:`, err); + }); + } } } - else { - // Server update failed - if (cacheResult.success) { - // Cache update did succeed, rollback to previous value - this.debug.error(`Failed to update server value for "${path}", rolling back cache to previous value. Error:`, serverResult.error) + else if (cacheResult.success) { + // Server was not updated, cache update was successful. + // Add pending sync action + + addPendingTransaction().catch(err => { + this.debug.error(`Failed to add pending sync action for "${path}", rolling back cache to previous value. Error:`, err); rollbackCache().catch(err => { this.debug.error(`Failed to roll back cache? Error:`, err); }); - } - } - } - else if (cacheResult.success) { - // Server was not updated, cache update was successful. - // Add pending sync action - - addPendingTransaction().catch(err => { - this.debug.error(`Failed to add pending sync action for "${path}", rolling back cache to previous value. Error:`, err); - rollbackCache().catch(err => { - this.debug.error(`Failed to roll back cache? Error:`, err); }); - }); - } - }); + } + }); if (!useServer) { // Fixes issue #7 @@ -1727,21 +1868,36 @@ class WebApi extends Api { } // return server promise by default, so caller can handle potential authorization issues - return this._cache.priority === 'cache' ? cachePromise : serverPromise; + return this._cache?.priority === 'cache' ? cachePromise : serverPromise as Promise<{ cursor?: string }>; } /** - * - * @param {string} path - * @param {object} [options] - * @param {'allow'|'bypass'|'force'} [options.cache_mode='allow'] If a cached value is allowed or forced to be served. - * @param {string} [options.cache_cursor] Use a cursor to update the local cache with mutations from the server, then load and serve the entire value from cache. Only works in combination with `cache_mode: 'allow'` (previously `allow_cache: true`) - * @param {string[]} [options.include] - * @param {string[]} [options.exclude] - * @param {boolean} [options.child_objects] - * @returns {Promise<{ value: any, context: object, cursor?: string }>} Returns a promise that resolves with the value, context and optionally a server cursor + * @returns Returns a promise that resolves with the value, context and optionally a server cursor */ - async get(path, options = { cache_mode: 'allow' }) { + async get( + path: string, + options: { + /** + * If a cached value is allowed or forced to be served. + * @default 'allow' + */ + cache_mode?: 'allow' | 'bypass' | 'force'; + /** + * Use a cursor to update the local cache with mutations from the server, then load and serve the entire value from cache. + * Only works in combination with `cache_mode: 'allow'` (previously `allow_cache: true`) + */ + cache_cursor?: string; + /** + * @deprecated use `cache_mode` option instead + */ + allow_cache?: boolean; + include?: (string | number)[]; + exclude?: (string | number)[]; + child_objects?: boolean; + } = { + cache_mode: 'allow', + }, + ): Promise<{ value: any, context: any, cursor?: string }> { if (typeof options.cache_mode !== 'string') { options.cache_mode = 'allow'; } const useCache = this._cache && options.cache_mode !== 'bypass'; const getServerValue = async () => { @@ -1749,14 +1905,14 @@ class WebApi extends Api { let url = `${this.url}/data/${this.dbname}/${path}`; let filtered = false; if (options) { - let query = []; - if (options.exclude instanceof Array) { - query.push(`exclude=${options.exclude.join(',')}`); + const query = [] as string[]; + if (options.exclude instanceof Array) { + query.push(`exclude=${options.exclude.join(',')}`); } - if (options.include instanceof Array) { - query.push(`include=${options.include.join(',')}`); + if (options.include instanceof Array) { + query.push(`include=${options.include.join(',')}`); } - if (typeof options.child_objects === "boolean") { + if (typeof options.child_objects === 'boolean') { query.push(`child_objects=${options.child_objects}`); } if (query.length > 0) { @@ -1777,14 +1933,15 @@ class WebApi extends Api { if (!filtered) { const cachePath = PathInfo.getChildPath(`${this.dbname}/cache`, path); this._cache.db.api.set(cachePath, value, { context: { acebase_operation: 'update_cache', acebase_server_context: context } }) - .catch(err => { - this.debug.error(`Error caching data for "/${path}"`, err) - }); + .catch(err => { + this.debug.error(`Error caching data for "/${path}"`, err); + }); } } return { value, context, cursor }; }; const getCacheValue = async (throwOnNull = false) => { + if (!this._cache) { throw new Error(`DEV ERROR: cannot get cached value if no cache is used!`); } const result = await this._cache.db.api.get(PathInfo.getChildPath(`${this.dbname}/cache`, path), options); let { value, context } = result; if (!('value' in result && 'context' in result)) { @@ -1829,9 +1986,9 @@ class WebApi extends Api { context.acebase_origin = 'server'; return { value, context, cursor }; } - if (!this.isConnected || this._cache.priority === 'cache') { + if (!this.isConnected || this._cache?.priority === 'cache') { // Server not connected, or priority is set to 'cache'. Get cached value - const throwOnNull = this._cache.priority !== 'cache'; + const throwOnNull = this._cache?.priority !== 'cache'; const { value, context } = await getCacheValue(throwOnNull); context.acebase_origin = 'cache'; return { value, context }; @@ -1839,7 +1996,7 @@ class WebApi extends Api { // Get both, use cached value if available and server version takes too long return new Promise((resolve, reject) => { let wait = true, done = false; - const gotValue = (source, val) => { + const gotValue = (source: 'cache' | 'server', val: { value: any; context: any; cursor?: string }) => { this.debug.verbose(`Got ${source} value of "${path}":`, val); if (done) { return; } const { value, context, cursor } = val; @@ -1852,7 +2009,7 @@ class WebApi extends Api { else if (value === null) { // Cached value is not available if (!wait) { - const serverError = errors.find(e => e.source === 'server').error; + const serverError = errors.find(e => e.source === 'server')?.error as any; if (serverError.isNetworkError) { // On network related errors, we thought we were connected but apparently weren't. // If we had known this up-front, getCachedValue(true) would have been executed and @@ -1862,12 +2019,12 @@ class WebApi extends Api { // Could not get server value because of a non-network related issue - possibly unauthorized access const error = new CachedValueUnavailableError(path, `Value for "${path}" not found in cache, and server value could not be loaded. See serverError for more details`); error.serverError = serverError; - return reject(error); + return reject(error); } } - else if (!wait) { + else if (!wait) { // Cached results, don't wait for server value - done = true; + done = true; this.debug.verbose(`Using cache value for "${path}"`); context.acebase_origin = 'cache'; resolve({ value, context }); @@ -1883,12 +2040,12 @@ class WebApi extends Api { }, 1000); } }; - let errors = []; - const gotError = (source, error) => { + const errors = [] as Array<{ source: 'cache' | 'server', error: any }>; + const gotError = (source: 'cache' | 'server', error: any) => { errors.push({ source, error }); - if (errors.length === 2) { + if (errors.length === 2) { // Both failed, reject with server error - reject(errors.find(e => e.source === 'server').error); + reject(errors.find(e => e.source === 'server')?.error); } }; @@ -1901,21 +2058,29 @@ class WebApi extends Api { .catch(err => gotError('cache', err)); }); } - - exists(path, options = { allow_cache: true }) { + + exists( + path: string, + options: { + allow_cache: boolean; + } = { + allow_cache: true, + }, + ): Promise { // TODO: refactor allow_cache to cache_mode // TODO: refactor to include context in return value: acebase_origin: 'cache' or 'server' const useCache = this._cache && options.allow_cache !== false; const getCacheExists = () => { + if (!this._cache) { throw new Error('DEV ERROR: no cache db available to check exists'); } return this._cache.db.api.exists(PathInfo.getChildPath(`${this.dbname}/cache`, path)); }; const getServerExists = () => { return this._request({ url: `${this.url}/exists/${this.dbname}/${path}` }) - .then(res => res.exists) - .catch(err => { - throw err; - }); - } + .then(res => res.exists) + .catch(err => { + throw err; + }); + }; if (!useCache) { return getServerExists(); } @@ -1926,16 +2091,16 @@ class WebApi extends Api { // Check both return new Promise((resolve, reject) => { let wait = true, done = false; - const gotExists = (source, exists) => { + const gotExists = (source: 'cache' | 'server', exists: boolean) => { if (done) { return; } if (source === 'server') { done = true; resolve(exists); } - else if (!wait) { + else if (!wait) { // Cached results, don't wait for server value - done = true; - resolve(exists); + done = true; + resolve(exists); } else { // Cached results, wait 1s before resolving with this value, server value might follow soon @@ -1946,10 +2111,10 @@ class WebApi extends Api { }, 1000); } }; - let errors = []; - const gotError = (source, error) => { + const errors = [] as Array<{ source: 'cache' | 'server', error: any }>; + const gotError = (source: 'cache' | 'server', error: any) => { errors.push({ source, error }); - if (errors.length === 2) { + if (errors.length === 2) { // Both failed, reject with server error reject(errors.find(e => e.source === 'server')); } @@ -1966,8 +2131,12 @@ class WebApi extends Api { } } - callExtension(method, path, data) { - method = method.toUpperCase(); + callExtension( + method: 'GET' | 'POST' | 'PUT' | 'DELETE', + path: string, + data: any, + ) { + method = method.toUpperCase() as typeof method; const postData = ['PUT','POST'].includes(method) ? data : null; let url = `${this.url}/ext/${this.dbname}/${path}`; if (data && !['PUT','POST'].includes(method)) { @@ -1977,7 +2146,7 @@ class WebApi extends Api { data = Object.keys(data) .filter(key => typeof data[key] !== 'undefined') .map(key => key + '=' + encodeURIComponent(JSON.stringify(data[key]))) - .join('&') + .join('&'); } else if (typeof data !== 'string' || !data.includes('=')) { throw new Error('data must be an object, or a string with query parameters, like "index=3&name=Something"'); @@ -1989,8 +2158,6 @@ class WebApi extends Api { /** * Clears the entire cache, or a specific path without raising any events - * @param {string} [path] - * @returns */ async clearCache(path = '') { if (this._cache) { @@ -2003,15 +2170,32 @@ class WebApi extends Api { /** * Updates the local cache with remote changes by retrieving all mutations to `path` since given `cursor` and applying them to the local cache database. * If the local path does not exist or no cursor is given, its entire value will be loaded from the server and stored in cache. If no cache database is used, an error will be thrown. - * @param {string} [path=''] Path to update. The root path will be used if not given, synchronizing the entire database. - * @param {string} [cursor] A previously acquired cursor to update the cache with. If not specified, `path`'s entire value will be loaded from the server. - * @returns {Promise<{ path: string, used_cursor: string, new_cursor: string, loaded_value: boolean, changes: Array<{ path: string, previous: any, value: any, context: any }> }>} */ - async updateCache(path = '', cursor) { + async updateCache( + /** + * Path to update. The root path will be used if not given, synchronizing the entire database. + */ + path = '', + /** + * A previously acquired cursor to update the cache with. If not specified, `path`'s entire value will be loaded from the server + */ + cursor: string | null, + ): Promise<{ + path: string; + used_cursor: string | null; + new_cursor: string; + loaded_value: boolean; + changes: Array<{ + path: string, + previous: any, + value: any, + context: any, + }>, + }> { if (!this._cache) { throw new Error(`No cache database used`); } const cachePath = PathInfo.getChildPath(`${this.dbname}/cache`, path); const cacheApi = this._cache.db.api; - let loadValue = cursor === null || typeof cursor === 'undefined' || !(await cacheApi.exists(cachePath)); + const loadValue = cursor === null || typeof cursor === 'undefined' || !(await cacheApi.exists(cachePath)); if (loadValue) { // Load from server, store in cache (.get takes care of that) const { value, context } = await this.get(path, { cache_mode: 'bypass' }); @@ -2019,7 +2203,7 @@ class WebApi extends Api { } // Get effective changes from server const { changes, new_cursor } = await this.getChanges({ path, cursor }); - for (let ch of changes) { + for (const ch of changes) { // Apply to local cache const cachePath = PathInfo.getChildPath(`${this.dbname}/cache`, ch.path); const options = { context: ch.context, suppress_events: false }; @@ -2034,46 +2218,36 @@ class WebApi extends Api { } /** - * - * @param {string} path - * @param {object} query - * @param {Array<{ key: string, op: string, compare: any}>} query.filters - * @param {number} query.skip number of results to skip, useful for paging - * @param {number} query.take max number of results to return - * @param {Array<{ key: string, ascending: boolean }>} query.order - * @param {object} [options] - * @param {boolean} [options.snapshots=false] whether to return matching data, or paths to matching nodes only - * @param {string[]} [options.include] when using snapshots, keys or relative paths to include in result data - * @param {string[]} [options.exclude] when using snapshots, keys or relative paths to exclude from result data - * @param {boolean} [options.child_objects] when using snapshots, whether to include child objects in result data - * @param {'allow'|'bypass'|'force'} [options.cache_mode] Whether to allow, force or bypass cache - * @param {(event: { name: string, [key]: any }) => void} [options.eventHandler] - * @param {object} [options.monitor] NEW (BETA) monitor changes - * @param {boolean} [options.monitor.add=false] monitor new matches (either because they were added, or changed and now match the query) - * @param {boolean} [options.monitor.change=false] monitor changed children that still match this query - * @param {boolean} [options.monitor.remove=false] monitor children that don't match this query anymore - * @ param {(event:string, path: string, value?: any) => boolean} [options.monitor.callback] NEW (BETA) callback with subscription to enable monitoring of new matches - * @returns {Promise<{ results: Array, context: any, stop(): Promise }} returns a promise that resolves with matching data or paths in `results` + * @returns returns a promise that resolves with matching data or paths in `results` */ - async query(path, query, options = { snapshots: false, cache_mode: 'allow', eventListener: undefined, monitor: { add: false, change: false, remove: false } }) { - const useCache = this._cache && (options.cache_mode === 'force' || (options.cache_mode === 'allow' && !this.isConnected)); + async query( + path: string, + query: Query, + options: QueryOptions = { snapshots: false, cache_mode: 'allow', monitor: { add: false, change: false, remove: false } }, + ): Promise<{ + results: Array<{ path: string; val: any; }> | string[]; + context: any; + stop(): Promise; + }> { + const useCache = this.hasCache && (options.cache_mode === 'force' || (options.cache_mode === 'allow' && !this.isConnected)); if (useCache) { // Not connected, or "force" cache_mode: query cache db - const data = await this._cache.db.api.query(PathInfo.getChildPath(`${this.dbname}/cache`, path), query, options); + const data = await this.cache.db.api.query(PathInfo.getChildPath(`${this.dbname}/cache`, path), query, options); let { results, context } = data; + const { stop } = data; if (!('results' in data && 'context' in data)) { // OLD api did not return context console.warn(`Missing context from local query results. Update your acebase package`); - results = data; + results = data as any as string[]; context = {}; } context.acebase_origin = 'cache'; delete context.acebase_cursor; // Do NOT pass along use cache cursor!! - return { results, context }; + return { results, context, stop }; } - const request = { + const request: { query: Query; options: QueryOptions; query_id?: string; client_id?: string } = { query, - options + options, }; if (options.monitor === true || (typeof options.monitor === 'object' && (options.monitor.add || options.monitor.change || options.monitor.remove))) { console.assert(typeof options.eventHandler === 'function', `no eventHandler specified to handle realtime changes`); @@ -2087,12 +2261,17 @@ class WebApi extends Api { const reqData = JSON.stringify(Transport.serialize(request)); try { const { data, context } = await this._request({ method: 'POST', url: `${this.url}/query/${this.dbname}/${path}`, data: reqData, includeContext: true }); - let results = Transport.deserialize(data); + const results = Transport.deserialize(data); context.acebase_origin = 'server'; const stop = async () => { // Stops subscription of realtime query results. Requires acebase-server v1.10.0+ - delete this._realtimeQueries[request.query_id]; - await _websocketRequest(socket, "query-unsubscribe", { query_id: request.query_id }); + delete this._realtimeQueries[request.query_id as string]; + await _websocketRequest( + this.socket, + 'query-unsubscribe', + { query_id: request.query_id as string }, + this.accessToken, + ); }; return { results: results.list, context, stop }; } @@ -2101,7 +2280,7 @@ class WebApi extends Api { } } - async createIndex(path, key, options) { + async createIndex(path: string, key: string, options: any) { if (options && options.config && Object.values(options.config).find(val => typeof val === 'function')) { throw new Error(`Cannot create an index with callback functions through a client. Move your code serverside`); } @@ -2109,7 +2288,7 @@ class WebApi extends Api { if (version.length === 3 && +version[0] >= 1 && +version[1] >= 10) { // acebase-server v1.10+ has a new dedicated endpoint at /index/dbname/create const data = JSON.stringify({ path, key, options }); - return await this._request({ method: 'POST', url: `${this.url}/index/${this.dbname}/create`, data }); + return await this._request({ method: 'POST', url: `${this.url}/index/${this.dbname}/create`, data }); } else { const data = JSON.stringify({ action: 'create', path, key, options }); @@ -2121,7 +2300,7 @@ class WebApi extends Api { return this._request({ url: `${this.url}/index/${this.dbname}` }); } - async deleteIndex(fileName) { + async deleteIndex(fileName: string) { // Requires acebase-server v1.10+ const version = this._serverVersion.split('.'); if (version.length === 3 && +version[0] >= 1 && +version[1] >= 10) { @@ -2133,10 +2312,10 @@ class WebApi extends Api { } } - reflect(path, type, args) { + reflect(path: string, type: 'info' | 'children', args: any) { let url = `${this.url}/reflect/${this.dbname}/${path}?type=${type}`; if (typeof args === 'object') { - let query = Object.keys(args).map(key => { + const query = Object.keys(args).map(key => { return `${key}=${args[key]}`; }); if (query.length > 0) { @@ -2146,17 +2325,18 @@ class WebApi extends Api { return this._request({ url }); } - export(path, write, options = { format: 'json', type_safe: true }) { + export(path: string, write: (chunk: string) => Promise, options = { format: 'json', type_safe: true }): ReturnType { options.format = 'json'; options.type_safe = options.type_safe !== false; - if (typeof write != 'function') { - write = write.write.bind(write); - } const url = `${this.url}/export/${this.dbname}/${path}?format=${options.format}&type_safe=${options.type_safe ? 1 : 0}`; - return this._request({ url, dataReceivedCallback: chunk => write(chunk) }); + return this._request({ url, dataReceivedCallback: chunk => write(chunk) }) as ReturnType; } - import(path, read, options = { format: 'json', suppress_events: false }) { + import( + path: string, + read: (length: number) => string | ArrayBufferView | Promise, + options = { format: 'json', suppress_events: false }, + ) { options.format = 'json'; options.suppress_events = options.suppress_events === true; const url = `${this.url}/import/${this.dbname}/${path}?format=${options.format}&suppress_events=${options.suppress_events ? 1 : 0}`; @@ -2179,34 +2359,23 @@ class WebApi extends Api { return info; } - setSchema(path, schema) { + setSchema(path: string, schema: string | Record) { if (schema !== null) { schema = (new SchemaDefinition(schema)).text; } - const data = JSON.stringify({ action: "set", path, schema }); - return this._request({ method: 'POST', url: `${this.url}/schema/${this.dbname}`, data }) - .catch(err => { - throw err; - }); + const data = JSON.stringify({ action: 'set', path, schema }); + return this._request({ method: 'POST', url: `${this.url}/schema/${this.dbname}`, data }); } - getSchema(path) { - return this._request({ url: `${this.url}/schema/${this.dbname}/${path}` }) - .catch(err => { - throw err; - }); + getSchema(path: string) { + return this._request({ url: `${this.url}/schema/${this.dbname}/${path}` }); } getSchemas() { - return this._request({ url: `${this.url}/schema/${this.dbname}` }) - .catch(err => { - throw err; - }); + return this._request({ url: `${this.url}/schema/${this.dbname}` }); } - validateSchema(path, value, isUpdate) { + async validateSchema(path: string, value: any, isUpdate: boolean): ReturnType { throw new Error(`Manual schema validation can only be used on standalone databases`); } } - -module.exports = { WebApi }; \ No newline at end of file diff --git a/src/ts/auth.ts b/src/ts/auth.ts index 8963a3b..533371f 100644 --- a/src/ts/auth.ts +++ b/src/ts/auth.ts @@ -1,95 +1,87 @@ -const { AceBaseUser, AceBaseSignInResult, AceBaseAuthResult } = require('./user'); -// const { AceBaseClient } = require('./acebase-client'); +import { AceBaseUser } from './user'; +import type { AceBaseClient } from './acebase-client'; -class AceBaseClientAuth { +export class AceBaseClientAuth { + public user: AceBaseUser | null = null; + public accessToken: string | null = null; - /** - * - * @param {AceBaseClient} client - */ - constructor(client, eventCallback) { - this.client = client; - this.eventCallback = eventCallback; - - this.user = null; - this.accessToken = null; - } + constructor(private client: AceBaseClient, private eventCallback: (event: string, data: any) => void) {} /** * Sign into a user account using a username and password. Note that the server must have authentication enabled. - * @param {string} username Your database username - * @param {string} password Your password - * @returns {Promise<{ user: AceBaseUser, accessToken: string }>} returns a promise that resolves with the signed in user and access token + * @param username A database username + * @param password The password + * @returns returns a promise that resolves with the signed in user and access token */ - async signIn(username, password) { + async signIn(username: string, password: string): Promise<{ user: AceBaseUser, accessToken: string }> { if (!this.client.isReady) { await this.client.ready(); } const details = await this.client.api.signIn(username, password); - if (this.user) { this.eventCallback("signout", { source: "signin", user: this.user }); } + if (this.user) { this.eventCallback('signout', { source: 'signin', user: this.user }); } this.accessToken = details.accessToken; this.user = new AceBaseUser(details.user); - this.eventCallback("signin", { source: "signin", user: this.user, accessToken: this.accessToken }); + this.eventCallback('signin', { source: 'signin', user: this.user, accessToken: this.accessToken }); return { user: this.user, accessToken: this.accessToken }; } /** * Sign into a user account using a username and password. Note that the server must have authentication enabled. - * @param {string} email Your email address - * @param {string} password Your password - * @returns {Promise<{ user: AceBaseUser, accessToken: string }>} returns a promise that resolves with the signed in user and access token + * @param email An email address + * @param password The password + * @returns returns a promise that resolves with the signed in user and access token */ - async signInWithEmail(email, password) { + async signInWithEmail(email: string, password: string): Promise<{ user: AceBaseUser, accessToken: string }> { if (!this.client.isReady) { await this.client.ready(); } const details = await this.client.api.signInWithEmail(email, password); - if (this.user) { this.eventCallback("signout", { source: "email_signin", user: this.user }); } + if (this.user) { this.eventCallback('signout', { source: 'email_signin', user: this.user }); } this.accessToken = details.accessToken; this.user = new AceBaseUser(details.user); - this.eventCallback("signin", { source: "email_signin", user: this.user, accessToken: this.accessToken }); - return { user: this.user, accessToken: this.accessToken }; //success: true, + this.eventCallback('signin', { source: 'email_signin', user: this.user, accessToken: this.accessToken }); + return { user: this.user, accessToken: this.accessToken }; //success: true, } /** - * Sign into an account using a previously assigned access token - * @param {string} accessToken a previously assigned access token - * @returns {Promise<{ user: AceBaseUser, accessToken: string }>} returns a promise that resolves with the signed in user and access token. If the token is not right, the thrown `error.code` will be `'not_found'` or `'invalid_token'` + * Sign into an account using a previously acquired access token + * @param accessToken a previously acquired access token + * @returns returns a promise that resolves with the signed in user and access token. If the token is not right, the thrown `error.code` will be `'not_found'` or `'invalid_token'` */ - async signInWithToken(accessToken) { + async signInWithToken(accessToken: string): Promise<{ user: AceBaseUser, accessToken: string }> { if (!this.client.isReady) { await this.client.ready(); } const details = await this.client.api.signInWithToken(accessToken); - if (this.user) { this.eventCallback("signout", { source: "token_signin", user: this.user }); } + if (this.user) { this.eventCallback('signout', { source: 'token_signin', user: this.user }); } this.accessToken = details.accessToken; this.user = new AceBaseUser(details.user); - this.eventCallback("signin", { source: "token_signin", user: this.user, accessToken: this.accessToken }); - return { user: this.user, accessToken: this.accessToken }; // success: true, + this.eventCallback('signin', { source: 'token_signin', user: this.user, accessToken: this.accessToken }); + return { user: this.user, accessToken: this.accessToken }; // success: true, } /** - * If the client is offline, you can specify an access token to automatically try signing in the user once a connection is made. + * If the client is offline, you can specify an access token to automatically try signing in the user once a connection is made. * Doing this is recommended if you are subscribing to event paths that require user authentication/authorization. Subscribing to * those server events will then be done after signing in, instead of failing after connecting anonymously. - * @param {string} accessToken A previously acquired access token + * @param accessToken A previously acquired access token */ - setAccessToken(accessToken) { + setAccessToken(accessToken: string) { this.client.api.setAccessToken(accessToken); } /** * If the server has been configured with OAuth providers, use this to kick off the authentication flow. - * This method returs a Promise that resolves with the url you have to redirect your user to authenticate + * This method returs a Promise that resolves with the url you have to redirect your user to authenticate * with the requested provider. After the user has authenticated, they will be redirected back to your callbackUrl. * Your code in the callbackUrl will have to call finishOAuthProviderSignIn with the result querystring parameter * to finish signing in. - * @param {string} providerName one of the configured providers (eg 'facebook', 'google', 'apple', 'spotify') - * @param {string} callbackUrl url on your website/app that will receive the sign in result - * @param {any} [options] optional provider specific authentication settings - * @returns {Promise} returns a Promise that resolves with the url you have to redirect your user to. + * @param providerName one of the configured providers (eg 'facebook', 'google', 'apple', 'spotify') + * @param callbackUrl url on your website/app that will receive the sign in result + * @param options optional provider specific authentication settings + * @returns returns a Promise that resolves with the url you have to redirect your user to. */ - async startAuthProviderSignIn(providerName, callbackUrl, options) { + async startAuthProviderSignIn(providerName: string, callbackUrl: string, options?: any): Promise { if (!this.client.isReady) { await this.client.ready(); } @@ -99,29 +91,25 @@ class AceBaseClientAuth { /** * Use this method to finish OAuth flow from your callbackUrl. - * @param {string} callbackResult result received in your.callback/url?result - * @returns {Promise<{ user: AceBaseUser, accessToken: string, provider: { name: string, access_token: string, refresh_token: string, expires_in: number } }>} + * @param callbackResult result received in your.callback/url?result */ - async finishAuthProviderSignIn(callbackResult) { + async finishAuthProviderSignIn(callbackResult: string): Promise<{ user: AceBaseUser, accessToken: string, provider: { name: string, access_token: string, refresh_token: string, expires_in: number } }> { if (!this.client.isReady) { await this.client.ready(); } const details = await this.client.api.finishAuthProviderSignIn(callbackResult); const isOtherUser = !this.user || this.user.uid !== details.user.uid; - isOtherUser && this.eventCallback("signout", { source: "oauth_signin", user: this.user }); + isOtherUser && this.eventCallback('signout', { source: 'oauth_signin', user: this.user }); this.accessToken = details.accessToken; this.user = new AceBaseUser(details.user); - isOtherUser && this.eventCallback("signin", { source: "oauth_signin", user: this.user, accessToken: this.accessToken }); - return { user: this.user, accessToken: this.accessToken, provider: details.provider }; // success: true, + isOtherUser && this.eventCallback('signin', { source: 'oauth_signin', user: this.user, accessToken: this.accessToken }); + return { user: this.user, accessToken: this.accessToken, provider: details.provider }; // success: true, } /** * Refreshes an expiring access token with the refresh token returned from finishAuthProviderSignIn - * @param {string} providerName - * @param {string} refreshToken - * @returns {Promise<{ provider: IAceBaseAuthProviderTokens }} */ - async refreshAuthProviderToken(providerName, refreshToken) { + async refreshAuthProviderToken(providerName: string, refreshToken: string) { if (!this.client.isReady) { await this.client.ready(); } @@ -133,9 +121,8 @@ class AceBaseClientAuth { * Signs in with an external auth provider by redirecting the user to the provider's login page. * After signing in, the user will be redirected to the current browser url. Execute * getRedirectResult() when your page is loaded again to check if the user was authenticated. - * @param {string} providerName */ - async signInWithRedirect(providerName) { + async signInWithRedirect(providerName: string) { if (typeof window === 'undefined') { throw new Error(`signInWithRedirect can only be used within a browser context`); } @@ -143,8 +130,8 @@ class AceBaseClientAuth { window.location.href = redirectUrl; } - /** - * Checks if the user authentication with an auth provider. + /** + * Checks if the user authentication with an auth provider. */ async getRedirectResult() { if (typeof window === 'undefined') { @@ -160,94 +147,121 @@ class AceBaseClientAuth { /** * Signs out of the current account - * @param {object|boolean} [options] options object, or boolean specifying whether to signout everywhere - * @param {boolean} [options.everywhere] whether to sign out all clients, or only this one - * @param {boolean} [options.clearCache] whether to clear the cache database (if used) - * @returns {Promise} returns a promise that resolves when user was signed out successfully + * @param options options object, or boolean specifying whether to signout everywhere + * @returnsreturns a promise that resolves when user was signed out successfully */ - async signOut(options) { + async signOut(options: boolean | { + /** + * whether to sign out all clients, or only this one + */ + everywhere?: boolean; + + /** + * whether to clear the cache database (if used) + */ + clearCache?: boolean; + }): Promise { if (!this.client.isReady) { await this.client.ready(); } else if (!this.user) { throw { code: 'not_signed_in', message: 'Not signed in!' }; } - if (this.client.isConnected) { + if (this.client.connected) { await this.client.api.signOut(options); } this.accessToken = null; - let user = this.user; + const user = this.user; this.user = null; - this.eventCallback("signout", { source: 'signout', user }); + this.eventCallback('signout', { source: 'signout', user }); } /** - * Changes the password of the currrently signed into account - * @param {string} oldPassword - * @param {string} newPassword - * @returns {Promise<{ accessToken: string }>} returns a promise that resolves with a new access token + * Changes the password of the currently signed into account + * @param oldPassword + * @param newPassword + * @returns returns a promise that resolves with a new access token */ - async changePassword(oldPassword, newPassword) { + async changePassword(oldPassword: string, newPassword: string): Promise<{ accessToken: string }> { if (!this.client.isReady) { await this.client.ready(); } - else if (!this.user) { + if (!this.user) { throw { code: 'not_signed_in', message: 'Not signed in!' }; } const result = await this.client.api.changePassword(this.user.uid, oldPassword, newPassword); this.accessToken = result.accessToken; - this.eventCallback("signin", { source: "password_change", user: this.user, accessToken: this.accessToken }); - return { accessToken: result.accessToken }; //success: true, + this.eventCallback('signin', { source: 'password_change', user: this.user, accessToken: this.accessToken }); + return { accessToken: result.accessToken }; //success: true, } /** * Requests a password reset for the account with specified email address - * @param {string} email - * @returns {Promise} returns a promise that resolves once the request has been processed + * @param email + * @returns returns a promise that resolves once the request has been processed */ - async forgotPassword(email) { + async forgotPassword(email: string): Promise { if (!this.client.isReady) { await this.client.ready(); } - return await this.client.api.forgotPassword(email); + await this.client.api.forgotPassword(email); } /** * Requests a password to be changed using a previously acquired reset code, sent to the email address with forgotPassword - * @param {string} resetCode - * @param {string} newPassword - * @returns {Promise} returns a promise that resolves once the password has been changed. The user is now able to sign in with the new password + * @param resetCode + * @param newPassword + * @returns returns a promise that resolves once the password has been changed. The user is now able to sign in with the new password */ - async resetPassword(resetCode, newPassword) { + async resetPassword(resetCode: string, newPassword: string): Promise { if (!this.client.isReady) { await this.client.ready(); } - return await this.client.api.resetPassword(resetCode, newPassword); + await this.client.api.resetPassword(resetCode, newPassword); } /** * Verifies an e-mail address using the code sent to the email address upon signing up - * @param {string} verificationCode - * @returns {Promise} returns a promise that resolves when verification was successful + * @param verificationCode + * @returns returns a promise that resolves when verification was successful */ - async verifyEmailAddress(verificationCode) { + async verifyEmailAddress(verificationCode: string): Promise { if (!this.client.isReady) { await this.client.ready(); } - return await this.client.api.verifyEmailAddress(verificationCode); + await this.client.api.verifyEmailAddress(verificationCode); } /** * Updates one or more user account details - * @param {object} details - * @param {string} [details.username] New username - * @param {string} [details.email] New email address - * @param {string} [details.display_name] New display name - * @param {{ url: string, width: number, height: number }} [details.picture] New profile picture - * @param {object} [details.settings] selection of user settings to update * @returns returns a promise with the updated user details */ - async updateUserDetails(details) { + async updateUserDetails(details: { + /** + * New username + */ + username?: string; + + /** + * New email address + */ + email?: string; + + /** + * New display name + */ + display_name?: string; + + /** + * New profile picture + */ + picture?: { url: string; width: number; height: number }; + + /** + * Selection of user settings to update + */ + settings?: Record; + }) { if (!this.client.isReady) { await this.client.ready(); } @@ -258,72 +272,81 @@ class AceBaseClientAuth { throw { code: 'invalid_details', message: 'details must be an object' }; } const result = await this.client.api.updateUserDetails(details); - Object.keys(result.user).forEach(key => { - this.user[key] = result.user[key]; - }); - return { user: this.user }; // success: true + if (!this.user) { + // Signed out in the mean time + return { user: null }; + } + for (const key of Object.keys(result.user)) { + this.user[key as keyof AceBaseUser] = result.user[key]; + } + return { user: this.user }; } /** * Changes the username of the currrently signed into account - * @param {string} newUsername - * @returns {Promise<{ user: AceBaseUser }>} returns a promise that resolves with the updated user details + * @returns returns a promise that resolves with the updated user details */ - async changeUsername(newUsername) { - return await this.updateUserDetails({ username: newUsername }); + async changeUsername(newUsername: string) { + return this.updateUserDetails({ username: newUsername }); } /** * Changes the display name of the currrently signed into account - * @param {string} newName - * @returns {Promise<{ user: AceBaseUser }>} returns a promise that resolves with the updated user details + * @param newName + * @returns returns a promise that resolves with the updated user details */ - async changeDisplayName(newName) { - return await this.updateUserDetails({ display_name: newName }); + async changeDisplayName(newName: string) { + return this.updateUserDetails({ display_name: newName }); } /** * Changes the email address of the currrently signed in user - * @param {string} newEmail - * @returns {Promise<{ user: AceBaseUser }>} returns a promise that resolves with the updated user details + * @param newEmail + * @returns returns a promise that resolves with the updated user details */ - async changeEmail(newEmail) { - return await this.updateUserDetails({ email: newEmail }); + async changeEmail(newEmail: string) { + return this.updateUserDetails({ email: newEmail }); } /** * Changes the user's profile picture - * @param {object} newPicture - * @param {string} newPicture.url - * @param {number} newPicture.width - * @param {number} newPicture.height - * @returns {Promise<{ user: AceBaseUser }>} returns a promise that resolves with the updated user details + * @returns returns a promise that resolves with the updated user details */ - async changePicture(newPicture) { + async changePicture(newPicture: { + url: string; + width: number; + height: number; + }) { return await this.updateUserDetails({ picture: newPicture }); } /** * Updates settings of the currrently signed in user. Passed settings will be merged with the user's current settings - * @param {{ [key:string]: string|number|boolean }} settings - the settings to update - * @returns {Promise<{ user: AceBaseUser }>} returns a promise that resolves with the updated user details + * @param settings the settings to update + * @returns returns a promise that resolves with the updated user details */ - async updateUserSettings(settings) { + async updateUserSettings(settings: Record) { return await this.updateUserDetails({ settings }); } /** - * Creates a new user account with the given details. If successful, you will automatically be + * Creates a new user account with the given details. If successful, you will automatically be * signed into the account. Note: the request will fail if the server has disabled this option - * @param {object} details - * @param {string} [details.username] - * @param {string} [details.email] - * @param {string} details.password - * @param {string} details.displayName - * @param {{ [key:string]: string|number|boolean }} [details.settings] optional settings - * @returns {Promise<{ user: AceBaseUser, accessToken: string }>} returns a promise that resolves with the signed in user and access token + * @returns returns a promise that resolves with the signed in user and access token */ - async signUp(details) { + async signUp(details: + ( + { username: string; email?: string; } + | { username?: string; email: string;} + ) & { + password: string; + displayName: string; + /** + * optional settings + */ + settings?: Record; + }, + ): Promise<{ user: AceBaseUser, accessToken?: string }> { if (!details.username && !details.email) { throw { code: 'invalid_details', message: 'No username or email set' }; } @@ -336,9 +359,9 @@ class AceBaseClientAuth { const isAdmin = this.user && this.user.uid === 'admin'; if (this.user && !isAdmin) { // Sign out of current account - let user = this.user; + const user = this.user; this.user = null; - this.eventCallback("signout", { source: 'signup', user } ); + this.eventCallback('signout', { source: 'signup', user } ); } const result = await this.client.api.signUp(details, !isAdmin); if (isAdmin) { @@ -348,8 +371,8 @@ class AceBaseClientAuth { // Sign into new account this.accessToken = result.accessToken; this.user = new AceBaseUser(result.user); - this.eventCallback("signin", { source: "signup", user: this.user, accessToken: this.accessToken }); - return { user: this.user, accessToken: this.accessToken }; //success: true, + this.eventCallback('signin', { source: 'signup', user: this.user, accessToken: this.accessToken }); + return { user: this.user, accessToken: this.accessToken as string }; //success: true, } } @@ -357,10 +380,9 @@ class AceBaseClientAuth { * Removes the currently signed in user account and signs out. Note: this will only * remove the database user account, not any data stored in the database by this user. It is * your own responsibility to remove that data. - * @param {string} [uid] for admin user only: remove account with uid - * @returns {Promise} + * @param uid for admin user only: remove account with specific uid */ - async deleteAccount(uid) { + async deleteAccount(uid?: string): Promise { if (!this.client.isReady) { await this.client.ready(); } @@ -379,11 +401,9 @@ class AceBaseClientAuth { if (signOut) { // Sign out of the account this.accessToken = null; - let user = this.user; + const user = this.user; this.user = null; - this.eventCallback("signout", { source: 'delete_account', user }); + this.eventCallback('signout', { source: 'delete_account', user }); } } } - -module.exports = { AceBaseClientAuth }; \ No newline at end of file diff --git a/src/ts/base64/browser.ts b/src/ts/base64/browser.ts index 7389023..088e221 100644 --- a/src/ts/base64/browser.ts +++ b/src/ts/base64/browser.ts @@ -1,9 +1,7 @@ -const Base64 = { - encode(str) { - return btoa(unescape(encodeURIComponent(str))); - }, - decode(base64) { - return decodeURIComponent(escape(atob(base64))); - } -}; -module.exports = Base64; \ No newline at end of file +export function encode(str: string) { + return btoa(unescape(encodeURIComponent(str))); +} + +export function decode(base64: string) { + return decodeURIComponent(escape(atob(base64))); +} diff --git a/src/ts/base64/index.ts b/src/ts/base64/index.ts index 17721ca..1c23215 100644 --- a/src/ts/base64/index.ts +++ b/src/ts/base64/index.ts @@ -1,9 +1,7 @@ -const Base64 = { - encode(str) { - return Buffer.from(str, 'utf8').toString('base64'); - }, - decode(base64) { - return Buffer.from(base64, 'base64').toString('utf8'); - } -}; -module.exports = Base64; \ No newline at end of file +export function encode(str: string) { + return Buffer.from(str, 'utf8').toString('base64'); +} + +export function decode(base64: string) { + return Buffer.from(base64, 'base64').toString('utf8'); +} diff --git a/src/ts/browser.ts b/src/ts/browser.ts index 8425119..000b088 100644 --- a/src/ts/browser.ts +++ b/src/ts/browser.ts @@ -12,8 +12,9 @@ */ -const acebaseclient = require('./index'); +import * as acebaseclient from './index'; -window.acebaseclient = acebaseclient; -window.AceBaseClient = acebaseclient.AceBaseClient; // Shortcut to AceBaseClient -module.exports = acebaseclient; \ No newline at end of file +(window as any).acebaseclient = acebaseclient; +(window as any).AceBaseClient = acebaseclient.AceBaseClient; // Shortcut to AceBaseClient + +export * from './index'; diff --git a/src/ts/errors.ts b/src/ts/errors.ts index ac75496..e788fb3 100644 --- a/src/ts/errors.ts +++ b/src/ts/errors.ts @@ -1,9 +1,7 @@ -class CachedValueUnavailableError extends Error { - constructor(path, message) { +export class CachedValueUnavailableError extends Error { + public serverError?: any; + constructor(public path: string, message?: string) { super(message || `Value for path "/${path}" is not available in cache`); - this.path = path; } } - -module.exports = { CachedValueUnavailableError }; \ No newline at end of file diff --git a/src/ts/index.ts b/src/ts/index.ts index 5ef4400..8e21e56 100644 --- a/src/ts/index.ts +++ b/src/ts/index.ts @@ -1,20 +1,4 @@ -const { DataReference, DataSnapshot, EventSubscription, PathReference, TypeMappings, ID, proxyAccess, ObjectCollection, PartialArray, Transport } = require('acebase-core'); -const { AceBaseClient } = require('./acebase-client'); -const { ServerDate } = require('./server-date'); -const { CachedValueUnavailableError } = require('./errors'); - -module.exports = { - AceBaseClient, - DataReference, - DataSnapshot, - EventSubscription, - PathReference, - TypeMappings, - ID, - proxyAccess, - ServerDate, - ObjectCollection, - CachedValueUnavailableError, - PartialArray, - Transport -}; \ No newline at end of file +export { DataReference, DataSnapshot, EventSubscription, PathReference, TypeMappings, ID, proxyAccess, ObjectCollection, PartialArray, Transport } from 'acebase-core'; +export { AceBaseClient } from './acebase-client'; +export { ServerDate } from './server-date'; +export { CachedValueUnavailableError } from './errors'; diff --git a/src/ts/performance/browser.ts b/src/ts/performance/browser.ts index 46d0528..5d04924 100644 --- a/src/ts/performance/browser.ts +++ b/src/ts/performance/browser.ts @@ -1 +1 @@ -module.exports = performance; \ No newline at end of file +export default performance; diff --git a/src/ts/performance/index.ts b/src/ts/performance/index.ts index 4c6eadb..9a7a540 100644 --- a/src/ts/performance/index.ts +++ b/src/ts/performance/index.ts @@ -1,2 +1,2 @@ -const { performance } = require('perf_hooks'); -module.exports = performance; \ No newline at end of file +import { performance } from 'perf_hooks'; +export default performance; diff --git a/src/ts/promise-timeout.ts b/src/ts/promise-timeout.ts index 77242e4..8208937 100644 --- a/src/ts/promise-timeout.ts +++ b/src/ts/promise-timeout.ts @@ -1,13 +1,11 @@ -class PromiseTimeoutError extends Error {} -function promiseTimeout(promise, ms, comment) { - return new Promise((resolve, reject) => { - let timeout; - function success(result) { +export class PromiseTimeoutError extends Error {} +export function promiseTimeout(promise: Promise, ms: number, comment?: string) { + return new Promise((resolve, reject) => { + const timeout: NodeJS.Timeout = setTimeout(() => reject(new PromiseTimeoutError(`Promise ${comment ? `"${comment}" ` : ''}timed out after ${ms}ms`)), ms); + function success(result: T) { clearTimeout(timeout); resolve(result); } promise.then(success).catch(reject); - timeout = setTimeout(() => reject(new PromiseTimeoutError(`Promise ${comment ? `"${comment}" ` : ''}timed out after ${ms}ms`)), ms); }); } -module.exports = { PromiseTimeoutError, promiseTimeout }; \ No newline at end of file diff --git a/src/ts/request/browser.ts b/src/ts/request/browser.ts index ccb6d4c..06b0845 100644 --- a/src/ts/request/browser.ts +++ b/src/ts/request/browser.ts @@ -1,9 +1,19 @@ -const { AceBaseRequestError } = require('./error'); +import { AceBaseRequestError } from './error'; /** - * @returns {Promise<{ context: any, data: any }>} returns a promise that resolves with an object containing data and an optionally returned context + * @returns returns a promise that resolves with an object containing data and an optionally returned context */ -async function request(method, url, options = { accessToken: null, data: null, dataReceivedCallback: null, dataRequestCallback: null, context: null }) { +export default async function request( + method: 'GET'|'POST'|'PUT'|'DELETE', + url: string, + options: { + accessToken?: string | null; + data?: any; + dataReceivedCallback?: ((chunk: any) => void) | null; + dataRequestCallback?: ((bytes: number) => Promise | any) | null; + context?: any; + } = { accessToken: null, data: null, dataReceivedCallback: null, dataRequestCallback: null, context: null }, +): Promise<{ context: any, data: any }> { let postData = options.data; if (typeof postData === 'undefined' || postData === null) { postData = ''; @@ -11,27 +21,28 @@ async function request(method, url, options = { accessToken: null, data: null, d else if (typeof postData === 'object') { postData = JSON.stringify(postData); } - const headers = { - 'AceBase-Context': JSON.stringify(options.context || null) + const headers: Record = { + 'AceBase-Context': JSON.stringify(options.context || null), }; const init = { method, - headers + headers, + body: undefined as any, }; if (typeof options.dataRequestCallback === 'function') { // Stream data to the server instead of posting all from memory at once headers['Content-Type'] = 'text/plain'; // Prevent server middleware parsing the content as JSON - + const supportsStreaming = false; if (supportsStreaming) { - // Streaming uploads appears not to be implemented in Chromium/Chrome yet. + // Streaming uploads appears not to be implemented in Chromium/Chrome yet. // Setting the body property to a ReadableStream results in the string "[object ReadableStream]" // See https://bugs.chromium.org/p/chromium/issues/detail?id=688906 and https://stackoverflow.com/questions/40939857/fetch-with-readablestream-as-request-body let canceled = false; init.body = new ReadableStream({ async pull(controller) { const chunkSize = controller.desiredSize || 1024 * 16; - let chunk = await options.dataRequestCallback(chunkSize); + const chunk = await options.dataRequestCallback?.(chunkSize); if (canceled || [null,''].includes(chunk)) { controller.close(); } @@ -39,10 +50,11 @@ async function request(method, url, options = { accessToken: null, data: null, d controller.enqueue(chunk); } }, + // eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars async start(controller) {}, cancel() { canceled = true; - } + }, }); } else { @@ -63,7 +75,7 @@ async function request(method, url, options = { accessToken: null, data: null, d if (options.accessToken) { headers['Authorization'] = `Bearer ${options.accessToken}`; } - const request = { url, method, headers }; + const request = { url, method, headers, body: undefined as any }; const res = await fetch(request.url, init).catch(err => { // console.error(err); throw new AceBaseRequestError(request, null, 'fetch_failed', err.message); @@ -71,21 +83,21 @@ async function request(method, url, options = { accessToken: null, data: null, d let data = ''; if (typeof options.dataReceivedCallback === 'function') { // Stream response - const reader = res.body.getReader(); - await new Promise((resolve, reject) => { - (function readNext() { - reader.read() - .then(result => { - options.dataReceivedCallback(result.value); - if (result.done) { return resolve(); } + const reader = res.body?.getReader(); + await new Promise((resolve, reject) => { + (async function readNext() { + try { + const result = await reader?.read(); + options.dataReceivedCallback?.(result?.value); + if (result?.done) { return resolve(); } readNext(); - }) - .catch(err => { - reader.cancel('error'); + } + catch (err) { + reader?.cancel('error'); reject(err); - }); + } })(); - }) + }); } else { data = await res.text(); @@ -93,8 +105,9 @@ async function request(method, url, options = { accessToken: null, data: null, d const isJSON = data[0] === '{' || data[0] === '['; // || (res.headers['content-type'] || '').startsWith('application/json') if (res.status === 200) { - let context = res.headers.get('AceBase-Context'); - if (context && context[0] === '{') { context = JSON.parse(context); } + const contextHeader = res.headers.get('AceBase-Context'); + let context: Record; + if (contextHeader && contextHeader[0] === '{') { context = JSON.parse(contextHeader); } else { context = {}; } if (isJSON) { data = JSON.parse(data); } return { context, data }; @@ -105,11 +118,11 @@ async function request(method, url, options = { accessToken: null, data: null, d statusCode: res.status, statusMessage: res.statusText, headers: res.headers, - body: data + body: data, }; let code = res.status, message = res.statusText; if (isJSON) { - let err = JSON.parse(data); + const err = JSON.parse(data); if (err.code) { code = err.code; } if (err.message) { message = err.message; } } @@ -118,4 +131,3 @@ async function request(method, url, options = { accessToken: null, data: null, d } -module.exports = request; \ No newline at end of file diff --git a/src/ts/request/error.ts b/src/ts/request/error.ts index f89482d..c7803a0 100644 --- a/src/ts/request/error.ts +++ b/src/ts/request/error.ts @@ -1,14 +1,9 @@ -class AceBaseRequestError extends Error { +export class AceBaseRequestError extends Error { get isNetworkError() { return this.response === null; } - constructor(request, response, code, message) { + constructor(public request: any, public response: any, public code?: number | string, public message: string = 'unknown error') { super(message); - this.code = code; - this.request = request; - this.response = response; } } -const NOT_CONNECTED_ERROR_MESSAGE = 'remote database is not connected'; //'AceBaseClient is not connected'; - -module.exports = { AceBaseRequestError, NOT_CONNECTED_ERROR_MESSAGE }; \ No newline at end of file +export const NOT_CONNECTED_ERROR_MESSAGE = 'remote database is not connected'; //'AceBaseClient is not connected'; diff --git a/src/ts/request/index.ts b/src/ts/request/index.ts index e2f6377..0f17c37 100644 --- a/src/ts/request/index.ts +++ b/src/ts/request/index.ts @@ -1,14 +1,24 @@ -const http = require('http'); -const https = require('https'); -const URL = require('url').URL; -const { AceBaseRequestError } = require('./error'); +import * as http from 'http'; +import * as https from 'https'; +import { URL } from 'url'; +import { AceBaseRequestError } from './error'; /** - * @returns {Promise<{ context: any, data: any }>} returns a promise that resolves with an object containing data and an optionally returned context + * @returns returns a promise that resolves with an object containing data and an optionally returned context */ -function request(method, url, options = { accessToken: null, data: null, dataReceivedCallback: null, dataRequestCallback: null, context: null }) { - return new Promise(async (resolve, reject) => { - let endpoint = new URL(url); // URL.parse(url); +export default function request( + method: 'GET'|'POST'|'PUT'|'DELETE', + url: string, + options: { + accessToken?: string | null; + data?: any; + dataReceivedCallback?: ((chunk: any) => void) | null; + dataRequestCallback?: ((bytes: number) => Promise | any) | null; + context?: any; + } = { accessToken: null, data: null, dataReceivedCallback: null, dataRequestCallback: null, context: null }, +) { + return new Promise<{ context: any, data: any }>(async (resolve, reject) => { + const endpoint = new URL(url); // URL.parse(url); let postData = options.data; if (typeof postData === 'undefined' || postData === null) { @@ -24,8 +34,9 @@ function request(method, url, options = { accessToken: null, data: null, dataRec port: endpoint.port, path: endpoint.pathname + endpoint.search, //endpoint.path, headers: { - 'AceBase-Context': JSON.stringify(options.context || null) - } + 'AceBase-Context': JSON.stringify(options.context || null), + } as Record, + body: undefined as any, }; if (method !== 'GET') { if (typeof options.dataRequestCallback !== 'function') { @@ -38,7 +49,7 @@ function request(method, url, options = { accessToken: null, data: null, dataRec } const client = request.protocol === 'https:' ? https : http; const req = client.request(request, res => { - res.setEncoding("utf8"); + res.setEncoding('utf8'); let data = ''; if (typeof options.dataReceivedCallback === 'function') { res.on('data', options.dataReceivedCallback); @@ -49,8 +60,9 @@ function request(method, url, options = { accessToken: null, data: null, dataRec res.on('end', () => { const isJSON = data[0] === '{' || data[0] === '['; // || (res.headers['content-type'] || '').startsWith('application/json') if (res.statusCode === 200) { - let context = res.headers['acebase-context']; // lowercase header names only - if (context && context[0] === '{') { context = JSON.parse(context); } + const contextHeader = res.headers['acebase-context'] as string; // lowercase header names only + let context: Record; + if (contextHeader && contextHeader[0] === '{') { context = JSON.parse(contextHeader); } else { context = {}; } if (isJSON) { data = JSON.parse(data); } resolve({ context, data }); @@ -61,11 +73,11 @@ function request(method, url, options = { accessToken: null, data: null, dataRec statusCode: res.statusCode, statusMessage: res.statusMessage, headers: res.headers, - body: data + body: data, }; let code = res.statusCode, message = res.statusMessage; if (isJSON) { - let err = JSON.parse(data); + const err = JSON.parse(data); if (err.code) { code = err.code; } if (err.message) { message = err.message; } } @@ -75,7 +87,7 @@ function request(method, url, options = { accessToken: null, data: null, dataRec }); req.on('error', (err) => { - reject(new AceBaseRequestError(request, null, err.code || err.name, err.message)); + reject(new AceBaseRequestError(request, null, (err as any).code || err.name, err.message)); }); if (typeof options.dataRequestCallback === 'function') { @@ -83,7 +95,7 @@ function request(method, url, options = { accessToken: null, data: null, dataRec const chunkSize = req.writableHighWaterMark || 1024 * 16; let chunk; while (![null,''].includes(chunk = await options.dataRequestCallback(chunkSize))) { - let ok = req.write(chunk); + const ok = req.write(chunk); if (!ok) { await new Promise(resolve => req.once('drain', resolve)); } } } @@ -92,6 +104,4 @@ function request(method, url, options = { accessToken: null, data: null, dataRec } req.end(); }); -}; - -module.exports = request; \ No newline at end of file +} diff --git a/src/ts/server-date.ts b/src/ts/server-date.ts index 6ca18c3..a3fdb48 100644 --- a/src/ts/server-date.ts +++ b/src/ts/server-date.ts @@ -1,12 +1,12 @@ -const { ID } = require('acebase-core'); -const performance = require('./performance'); +import { ID } from 'acebase-core'; +import performance from './performance'; -let time = { +const time = { serverBias: 0, localBias: 0, lastTime: Date.now(), lastPerf: performance.now(), - get bias() { return this.serverBias + this.localBias; } + get bias() { return this.serverBias + this.localBias; }, }; function biasChanged() { @@ -39,7 +39,7 @@ function scheduleLocalTimeCheck() { } scheduleLocalTimeCheck(); -function setServerBias(bias) { +export function setServerBias(bias: number) { if (typeof bias === 'number') { time.serverBias = bias; time.localBias = 0; @@ -47,11 +47,9 @@ function setServerBias(bias) { } } -class ServerDate extends Date { +export class ServerDate extends Date { constructor() { const biasedTime = Date.now() + time.bias; super(biasedTime); } } - -module.exports = { ServerDate, setServerBias }; \ No newline at end of file diff --git a/src/ts/user.ts b/src/ts/user.ts index 447137e..80d8f6f 100644 --- a/src/ts/user.ts +++ b/src/ts/user.ts @@ -1,62 +1,85 @@ - -class AceBaseUser { +export class AceBaseUser { /** - * - * @param {{ uid: string, username?: string, email?: string, displayName: string, created: Date, last_signin: Date, last_signin_ip: string settings: { [key:string]: string|number|boolean } }} user + * unique id */ - constructor(user) { - // /** @type {string} unique id */ - // this.uid = user.uid; - // /** @type {string?} username */ - // this.username = user.username; - // /** @type {string?} email address */ - // this.email = user.email; - // /** @type {string?} display or screen name */ - // this.displayName = user.displayName; - // this.settings = user.settings; - // this.created = user.created; - // this.last_signin = user.last_signin; - // this.last_signin_ip = user.last_signin_ip; - // this.prev_signin = user.prev_signin; - // this.prev_signin_ip = user.prev_signin_ip; + uid: string; + + /** + * username used for signing in + */ + username?: string; + + /** + * email address used for signing in + */ + email?: string; + + /** + * display or screen name + */ + displayName: string; + + /** + * Date/time this user record was created + */ + created: Date; + + /** + * Date/time this user previously signed in + */ + last_signin?: Date; + + /** + * IP address of last signin + */ + last_signin_ip?: string; + + /** + * Additional saved user settings & info + */ + settings: { [key:string]: string|number|boolean }; + + constructor(user: Partial) { Object.assign(this, user); + if (!user.uid) { throw new Error('User details is missing required uid field'); } + this.uid = user.uid; + this.displayName = user.displayName ?? 'unknown'; + this.created = user.created ?? new Date(0); + this.settings = user.settings ?? {}; } } -class AceBaseSignInResult { - /** - * - * @param {object} result - * @param {boolean} result.success - * @param {AceBaseUser} [result.user] - * @param {string} [result.accessToken] - * @param {{ code: string, message: string }} [result.reason] - */ - constructor(result) { - this.success = result.success; - if (result.success) { - this.user = result.user; - this.accessToken = result.accessToken; - } - else { - this.reason = result.reason; - } - } -} +export type AceBaseSignInSuccess = { success: true; user: AceBaseUser; accessToken: string; provider: string }; +export type AceBaseSignInFailure = { success: false; reason: { code: string, message: string }; }; +export type AceBaseSignInResult = AceBaseSignInSuccess | AceBaseSignInFailure; +// export class AceBaseSignInResult { +// success: boolean; +// user?: AceBaseUser; +// accessToken?: string; +// reason?: { code: string, message: string }; +// constructor(result: { success: boolean; user?: AceBaseUser; accessToken?: string; reason?: { code: string, message: string } }) { +// this.success = result.success; +// if (result.success) { +// this.user = result.user; +// this.accessToken = result.accessToken; +// } +// else { +// this.reason = result.reason; +// } +// } +// } -class AceBaseAuthResult { - /** - * - * @param {object} result - * @param {boolean} result.success - * @param {{ code: string, message: string }} [result.reason] - */ - constructor(result) { - this.success = result.success; - if (!result.success) { - this.reason = result.reason; - } - } -} +export type AceBaseAuthSuccess = { success: true }; +export type AceBaseAuthFailure = { success: false; reason: { code: string, message: string } }; +export type AceBaseAuthResult = AceBaseAuthSuccess | AceBaseAuthFailure; -module.exports = { AceBaseUser, AceBaseSignInResult, AceBaseAuthResult }; \ No newline at end of file +// export class AceBaseAuthResult { +// success: boolean; +// reason?: { code: string, message: string }; +// constructor(result: { success: boolean; reason?: { code: string, message: string }}) { +// this.success = result.success; +// if (!result.success) { +// this.reason = result.reason; +// } +// } +// }