import { trace } from '@opentelemetry/api';
import { Lock } from '@rocicorp/lock';
import { resolver } from '@rocicorp/resolver';
import { manualSpan, startAsyncSpan, startSpan, } from "../../../../otel/src/span.js";
import { version } from "../../../../otel/src/version.js";
import { assert, unreachable } from "../../../../shared/src/asserts.js";
import { stringify } from "../../../../shared/src/bigint-json.js";
import { CustomKeyMap } from "../../../../shared/src/custom-key-map.js";
import { wrapIterable } from "../../../../shared/src/iterables.js";
import { must } from "../../../../shared/src/must.js";
import { randInt } from "../../../../shared/src/rand.js";
import { ErrorKind } from "../../../../zero-protocol/src/error-kind.js";
import { clampTTL, MAX_TTL_MS } from "../../../../zql/src/query/ttl.js";
import { transformAndHashQuery, } from "../../auth/read-authorizer.js";
import { CustomQueryTransformer } from "../../custom-queries/transform-query.js";
import { getOrCreateCounter, getOrCreateHistogram, getOrCreateUpDownCounter, } from "../../observability/metrics.js";
import { InspectorDelegate } from "../../server/inspector-delegate.js";
import { ErrorForClient, getLogLevel } from "../../types/error-for-client.js";
import { rowIDString } from "../../types/row-key.js";
import { Subscription } from "../../types/subscription.js";
import { ZERO_VERSION_COLUMN_NAME } from "../replicator/schema/replication-state.js";
import { ClientHandler, startPoke, } from "./client-handler.js";
import { CVRStore } from "./cvr-store.js";
import { CVRConfigDrivenUpdater, CVRQueryDrivenUpdater, CVRUpdater, nextEvictionTime, } from "./cvr.js";
import { handleInspect } from "./inspect-handler.js";
import { PipelineDriver } from "./pipeline-driver.js";
import { cmpVersions, EMPTY_CVR_VERSION, versionFromString, versionString, versionToCookie, } from "./schema/types.js";
import { ResetPipelinesSignal } from "./snapshotter.js";
import { ttlClockAsNumber, ttlClockFromNumber, } from "./ttl-clock.js";
const tracer = trace.getTracer('view-syncer', version);
const PROTOCOL_VERSION_ATTR = 'protocol.version';
const DEFAULT_KEEPALIVE_MS = 5_000;
function randomID() {
    return randInt(1, Number.MAX_SAFE_INTEGER).toString(36);
}
/**
 * We update the ttlClock in flush that writes to the CVR but
 * some flushes do not write to the CVR and in those cases we
 * use a timer to update the ttlClock every minute.
 */
export const TTL_CLOCK_INTERVAL = 60_000;
/**
 * This is some extra time we delay the TTL timer to allow for some
 * slack in the timing of the timer. This is to allow multiple evictions
 * to happen in a short period of time without having to wait for the
 * next tick of the timer.
 */
export const TTL_TIMER_HYSTERESIS = 50; // ms
export class ViewSyncerService {
    id;
    #shard;
    #lc;
    #pipelines;
    #stateChanges;
    #drainCoordinator;
    #keepaliveMs;
    #slowHydrateThreshold;
    #queryConfig;
    userQueryURL;
    // The ViewSyncerService is only started in response to a connection,
    // so #lastConnectTime is always initialized to now(). This is necessary
    // to handle race conditions in which, e.g. the replica is ready and the
    // CVR is accessed before the first connection sends a request.
    //
    // Note: It is fine to update this variable outside of the lock.
    #lastConnectTime = Date.now();
    /**
     * The TTL clock is used to determine the time at which queries are considered
     * expired.
     */
    #ttlClock;
    /**
     * The base time for the TTL clock. This is used to compute the current TTL
     * clock value. The first time a connection is made, this is set to the
     * current time. On subsequent connections, the TTL clock is computed as the
     * difference between the current time and this base time.
     *
     * Every time we write the ttlClock this is update to the current time. That
     * way we can compute how much time has passed since the last time we set the
     * ttlClock. When we set the ttlClock we just increment it by the amount of
     * time that has passed since the last time we set it.
     */
    #ttlClockBase = Date.now();
    /**
     * We update the ttlClock every minute to ensure that it is not too much
     * out of sync with the current time.
     */
    #ttlClockInterval = 0;
    // Note: It is okay to add/remove clients without acquiring the lock.
    #clients = new Map();
    // Serialize on this lock for:
    // (1) storage or database-dependent operations
    // (2) updating member variables.
    #lock = new Lock();
    #cvrStore;
    #stopped = resolver();
    #initialized = resolver();
    #cvr;
    #pipelinesSynced = false;
    // DEPRECATED: remove `authData` in favor of forwarding
    // auth and cookie headers directly
    #authData;
    // Not sure why lint can't see that this is used?
    // oxlint-disable-next-line no-unused-private-class-members
    #httpCookie;
    #expiredQueriesTimer = 0;
    #setTimeout;
    #customQueryTransformer;
    #activeClients = getOrCreateUpDownCounter('sync', 'active-clients', 'Number of active sync clients');
    #hydrations = getOrCreateCounter('sync', 'hydration', 'Number of query hydrations');
    #hydrationTime = getOrCreateHistogram('sync', 'hydration-time', {
        description: 'Time to hydrate a query.',
        unit: 's',
    });
    #transactionAdvanceTime = getOrCreateHistogram('sync', 'advance-time', {
        description: 'Time to advance all queries for a given client group after applying a new transaction to the replica.',
        unit: 's',
    });
    #inspectorDelegate;
    #config;
    constructor(config, lc, shard, taskID, clientGroupID, cvrDb, upstreamDb, pipelineDriver, versionChanges, drainCoordinator, slowHydrateThreshold, inspectorDelegate, customQueryTransformer, keepaliveMs = DEFAULT_KEEPALIVE_MS, setTimeoutFn = setTimeout.bind(globalThis)) {
        const { getQueries: pullConfig } = config;
        this.#config = config;
        this.id = clientGroupID;
        this.#shard = shard;
        this.#queryConfig = pullConfig;
        this.#lc = lc;
        this.#pipelines = pipelineDriver;
        this.#stateChanges = versionChanges;
        this.#drainCoordinator = drainCoordinator;
        this.#keepaliveMs = keepaliveMs;
        this.#slowHydrateThreshold = slowHydrateThreshold;
        this.#inspectorDelegate = inspectorDelegate;
        this.#customQueryTransformer = customQueryTransformer;
        this.#cvrStore = new CVRStore(lc, cvrDb, upstreamDb, shard, taskID, clientGroupID, 
        // On failure, cancel the #stateChanges subscription. The run()
        // loop will then await #cvrStore.flushed() which rejects if necessary.
        () => this.#stateChanges.cancel());
        this.#setTimeout = setTimeoutFn;
        // Wait for the first connection to init.
        this.keepalive();
    }
    #getHeaderOptions(forwardCookie) {
        return {
            apiKey: this.#queryConfig.apiKey,
            token: this.#authData?.raw,
            cookie: forwardCookie ? this.#httpCookie : undefined,
        };
    }
    #runInLockWithCVR(fn) {
        const rid = randomID();
        this.#lc.debug?.('about to acquire lock for cvr ', rid);
        return this.#lock.withLock(async () => {
            this.#lc.debug?.('acquired lock in #runInLockWithCVR ', rid);
            const lc = this.#lc.withContext('lock', rid);
            if (!this.#stateChanges.active) {
                // view-syncer has been shutdown. this can be a backlog of tasks
                // queued on the lock, or it can be a race condition in which a
                // client connects before the ViewSyncer has been deleted from the
                // ServiceRunner.
                this.#lc.debug?.('state changes are inactive');
                clearTimeout(this.#expiredQueriesTimer);
                throw new ErrorForClient({
                    kind: ErrorKind.Rehome,
                    message: 'Reconnect required',
                }, 'warn');
            }
            // If all clients have disconnected, cancel all pending work.
            if (await this.#checkForShutdownConditionsInLock()) {
                this.#lc.info?.(`closing clientGroupID=${this.id}`);
                this.#stateChanges.cancel(); // Note: #stateChanges.active becomes false.
                return;
            }
            if (!this.#cvr) {
                this.#lc.debug?.('loading CVR');
                this.#cvr = await this.#cvrStore.load(lc, this.#lastConnectTime);
                this.#ttlClock = this.#cvr.ttlClock;
                this.#ttlClockBase = Date.now();
            }
            else {
                // Make sure the CVR ttlClock is up to date.
                const now = Date.now();
                this.#cvr = {
                    ...this.#cvr,
                    ttlClock: this.#getTTLClock(now),
                };
            }
            try {
                await fn(lc, this.#cvr);
            }
            catch (e) {
                // Clear cached state if an error is encountered.
                this.#cvr = undefined;
                throw e;
            }
        });
    }
    readyState() {
        return Promise.race([
            this.#initialized.promise,
            this.#drainCoordinator.draining,
        ]);
    }
    async run() {
        try {
            // Wait for initialization if we need to process queries.
            // This ensures authData is available before transforming custom queries.
            if ((await this.readyState()) === 'draining') {
                this.#lc.debug?.(`draining view-syncer ${this.id} before running`);
                void this.stop();
            }
            for await (const { state } of this.#stateChanges) {
                if (this.#drainCoordinator.shouldDrain()) {
                    this.#lc.debug?.(`draining view-syncer ${this.id} (elective)`);
                    break;
                }
                assert(state === 'version-ready', 'state should be version-ready'); // This is the only state change used.
                await this.#runInLockWithCVR(async (lc, cvr) => {
                    if (!this.#pipelines.initialized()) {
                        // On the first version-ready signal, connect to the replica.
                        this.#pipelines.init(cvr.clientSchema);
                    }
                    if (cvr.replicaVersion !== null &&
                        cvr.version.stateVersion !== '00' &&
                        this.#pipelines.replicaVersion < cvr.replicaVersion) {
                        const message = `Cannot sync from older replica: CVR=${cvr.replicaVersion}, DB=${this.#pipelines.replicaVersion}`;
                        lc.info?.(`resetting CVR: ${message}`);
                        throw new ErrorForClient({ kind: ErrorKind.ClientNotFound, message });
                    }
                    if (this.#pipelinesSynced) {
                        const result = await this.#advancePipelines(lc, cvr);
                        if (result === 'success') {
                            return;
                        }
                        lc.info?.(`resetting pipelines: ${result.message}`);
                        this.#pipelines.reset(cvr.clientSchema);
                    }
                    // Advance the snapshot to the current version.
                    const version = this.#pipelines.advanceWithoutDiff();
                    const cvrVer = versionString(cvr.version);
                    if (version < cvr.version.stateVersion) {
                        lc.debug?.(`replica@${version} is behind cvr@${cvrVer}`);
                        return; // Wait for the next advancement.
                    }
                    // stateVersion is at or beyond CVR version for the first time.
                    lc.info?.(`init pipelines@${version} (cvr@${cvrVer})`);
                    await this.#hydrateUnchangedQueries(lc, cvr);
                    await this.#syncQueryPipelineSet(lc, cvr);
                    this.#pipelinesSynced = true;
                });
            }
            // If this view-syncer exited due to an elective or forced drain,
            // set the next drain timeout.
            if (this.#drainCoordinator.shouldDrain()) {
                this.#drainCoordinator.drainNextIn(this.#totalHydrationTimeMs());
            }
            this.#cleanup();
        }
        catch (e) {
            this.#lc[getLogLevel(e)]?.(`stopping view-syncer: ${String(e)}`, e);
            this.#cleanup(e);
        }
        finally {
            // Always wait for the cvrStore to flush, regardless of how the service
            // was stopped.
            await this.#cvrStore
                .flushed(this.#lc)
                .catch(e => this.#lc[getLogLevel(e)]?.(e));
            this.#lc.info?.('view-syncer stopped');
            this.#stopped.resolve();
        }
    }
    // must be called from within #lock
    #removeExpiredQueries = async (lc, cvr) => {
        if (hasExpiredQueries(cvr)) {
            lc = lc.withContext('method', '#removeExpiredQueries');
            lc.debug?.('Queries have expired');
            // #syncQueryPipelineSet() will remove the expired queries.
            await this.#syncQueryPipelineSet(lc, cvr);
            this.#pipelinesSynced = true;
        }
        // Even if we have expired queries, we still need to schedule next eviction
        // since there might be inactivated queries that need to be expired queries
        // in the future.
        this.#scheduleExpireEviction(lc, cvr);
    };
    #totalHydrationTimeMs() {
        return this.#pipelines.totalHydrationTimeMs();
    }
    #keepAliveUntil = 0;
    /**
     * Guarantees that the ViewSyncer will remain running for at least
     * its configured `keepaliveMs`. This is called when establishing a
     * new connection to ensure that its associated ViewSyncer isn't
     * shutdown before it receives the connection.
     *
     * @return `true` if the ViewSyncer will stay alive, `false` if the
     *         ViewSyncer is shutting down.
     */
    keepalive() {
        if (!this.#stateChanges.active) {
            return false;
        }
        this.#keepAliveUntil = Date.now() + this.#keepaliveMs;
        return true;
    }
    // oxlint-disable-next-line no-unused-private-class-members -- False positive, used in #scheduleShutdown
    #shutdownTimer = null;
    #scheduleShutdown(delayMs = 0) {
        this.#shutdownTimer ??= this.#setTimeout(() => {
            this.#shutdownTimer = null;
            // All lock tasks check for shutdown so that queued work is immediately
            // canceled when clients disconnect. Queue an empty task to ensure that
            // this check happens.
            void this.#runInLockWithCVR(() => { }).catch(e => 
            // If an error occurs (e.g. ownership change), propagate the error
            // to the main run() loop via the #stateChanges Subscription.
            this.#stateChanges.fail(e));
        }, delayMs);
    }
    async #checkForShutdownConditionsInLock() {
        if (this.#clients.size > 0) {
            return false; // common case.
        }
        // Keep the view-syncer alive if there are pending rows being flushed.
        // It's better to do this before shutting down since it may take a
        // while, during which new connections may come in.
        await this.#cvrStore.flushed(this.#lc);
        if (Date.now() <= this.#keepAliveUntil) {
            this.#scheduleShutdown(this.#keepaliveMs); // check again later
            return false;
        }
        // If no clients have connected while waiting for the row flush, shutdown.
        return this.#clients.size === 0;
    }
    #deleteClientDueToDisconnect(clientID, client) {
        // Note: It is okay to delete / cleanup clients without acquiring the lock.
        // In fact, it is important to do so in order to guarantee that idle cleanup
        // is performed in a timely manner, regardless of the amount of work
        // queued on the lock.
        const c = this.#clients.get(clientID);
        if (c === client) {
            this.#clients.delete(clientID);
            if (this.#clients.size === 0) {
                // It is possible to delete a client before we read the ttl clock from
                // the CVR.
                if (this.#ttlClock !== undefined) {
                    this.#updateTTLClockInCVRWithoutLock(this.#lc);
                }
                this.#stopExpireTimer();
                this.#scheduleShutdown();
            }
        }
    }
    #stopExpireTimer() {
        this.#lc.debug?.('Stopping expired queries timer');
        clearTimeout(this.#expiredQueriesTimer);
        this.#expiredQueriesTimer = 0;
    }
    initConnection(ctx, initConnectionMessage) {
        this.#lc.debug?.('viewSyncer.initConnection');
        return startSpan(tracer, 'vs.initConnection', () => {
            const { clientID, wsID, baseCookie, schemaVersion, tokenData, httpCookie, protocolVersion, } = ctx;
            this.#authData = pickToken(this.#lc, this.#authData, tokenData);
            this.#initialized.resolve('initialized'); // Signal that initialization is complete.
            this.#lc.debug?.(`Picked auth token: ${JSON.stringify(this.#authData?.decoded)}`);
            this.#httpCookie = httpCookie;
            // Handle custom query URL
            const [, { userQueryURL }] = initConnectionMessage;
            if (this.userQueryURL === undefined) {
                // First client in the group - store its parameters
                this.userQueryURL = userQueryURL;
            }
            else {
                // Validate that subsequent clients have compatible parameters
                if (this.userQueryURL !== userQueryURL) {
                    this.#lc.error?.('Client provided different query parameters than client group', {
                        clientID,
                        clientURL: userQueryURL,
                        clientGroupURL: this.userQueryURL,
                    });
                }
            }
            const lc = this.#lc
                .withContext('clientID', clientID)
                .withContext('wsID', wsID);
            // Setup the downstream connection.
            const downstream = Subscription.create({
                cleanup: (_, err) => {
                    err
                        ? lc[getLogLevel(err)]?.(`client closed with error`, err)
                        : lc.info?.('client closed');
                    this.#deleteClientDueToDisconnect(clientID, newClient);
                    this.#activeClients.add(-1, {
                        [PROTOCOL_VERSION_ATTR]: protocolVersion,
                    });
                },
            });
            this.#activeClients.add(1, {
                [PROTOCOL_VERSION_ATTR]: protocolVersion,
            });
            if (this.#clients.size === 0) {
                // First connection to this ViewSyncerService.
                // initConnection must be synchronous so that the downstream
                // subscription is returned immediately.
                const now = Date.now();
                this.#ttlClockBase = now;
            }
            const newClient = new ClientHandler(lc, this.id, clientID, wsID, this.#shard, baseCookie, schemaVersion, downstream);
            this.#clients.get(clientID)?.close(`replaced by wsID: ${wsID}`);
            this.#clients.set(clientID, newClient);
            // Note: initConnection() must be synchronous so that `downstream` is
            // immediately returned to the caller (connection.ts). This ensures
            // that if the connection is subsequently closed, the `downstream`
            // subscription can be properly canceled even if #runInLockForClient()
            // has not had a chance to run.
            void this.#runInLockForClient(ctx, initConnectionMessage, this.#handleConfigUpdate, newClient).catch(e => newClient.fail(e));
            return downstream;
        });
    }
    async changeDesiredQueries(ctx, msg) {
        await this.#runInLockForClient(ctx, msg, this.#handleConfigUpdate);
    }
    async deleteClients(ctx, msg) {
        try {
            await this.#runInLockForClient(ctx, [msg[0], { deleted: msg[1] }], this.#handleConfigUpdate);
        }
        catch (e) {
            this.#lc.error?.('deleteClients failed', e);
        }
    }
    #getTTLClock(now) {
        // We will update ttlClock with delta from the ttlClockBase to the current time.
        const delta = now - this.#ttlClockBase;
        assert(this.#ttlClock !== undefined, 'ttlClock should be defined');
        const ttlClock = ttlClockFromNumber(ttlClockAsNumber(this.#ttlClock) + delta);
        assert(ttlClockAsNumber(ttlClock) <= now, 'ttlClock should be less than or equal to now');
        this.#ttlClock = ttlClock;
        this.#ttlClockBase = now;
        return ttlClock;
    }
    async #flushUpdater(lc, updater) {
        const now = Date.now();
        const ttlClock = this.#getTTLClock(now);
        const { cvr, flushed } = await updater.flush(lc, this.#lastConnectTime, now, ttlClock);
        if (flushed) {
            // If the CVR was flushed, we restart the ttlClock interval.
            this.#startTTLClockInterval(lc);
        }
        return cvr;
    }
    #startTTLClockInterval(lc) {
        this.#stopTTLClockInterval();
        this.#ttlClockInterval = this.#setTimeout(() => {
            this.#updateTTLClockInCVRWithoutLock(lc);
            this.#startTTLClockInterval(lc);
        }, TTL_CLOCK_INTERVAL);
    }
    #stopTTLClockInterval() {
        clearTimeout(this.#ttlClockInterval);
        this.#ttlClockInterval = 0;
    }
    #updateTTLClockInCVRWithoutLock(lc) {
        lc.debug?.('Syncing ttlClock');
        const now = Date.now();
        const ttlClock = this.#getTTLClock(now);
        this.#cvrStore.updateTTLClock(ttlClock, now).catch(e => {
            lc.error?.('failed to update TTL clock', e);
        });
    }
    async #updateCVRConfig(lc, cvr, clientID, fn) {
        const updater = new CVRConfigDrivenUpdater(this.#cvrStore, cvr, this.#shard);
        updater.ensureClient(clientID);
        const patches = fn(updater);
        this.#cvr = await this.#flushUpdater(lc, updater);
        if (cmpVersions(cvr.version, this.#cvr.version) < 0) {
            // Send pokes to catch up clients that are up to date.
            // (Clients that are behind the cvr.version need to be caught up in
            //  #syncQueryPipelineSet(), as row data may be needed for catchup)
            const newCVR = this.#cvr;
            const pokers = startPoke(this.#getClients(cvr.version), newCVR.version);
            for (const patch of patches) {
                await pokers.addPatch(patch);
            }
            await pokers.end(newCVR.version);
        }
        if (this.#pipelinesSynced) {
            await this.#syncQueryPipelineSet(lc, this.#cvr);
        }
        return this.#cvr;
    }
    /**
     * Runs the given `fn` to process the `msg` from within the `#lock`,
     * optionally adding the `newClient` if supplied.
     */
    #runInLockForClient(ctx, msg, fn, newClient) {
        this.#lc.debug?.('viewSyncer.#runInLockForClient');
        const { clientID, wsID } = ctx;
        const [cmd, body] = msg;
        if (newClient || !this.#clients.has(clientID)) {
            this.#lastConnectTime = Date.now();
        }
        return startAsyncSpan(tracer, `vs.#runInLockForClient(${cmd})`, async () => {
            let client;
            try {
                await this.#runInLockWithCVR((lc, cvr) => {
                    lc = lc
                        .withContext('clientID', clientID)
                        .withContext('wsID', wsID)
                        .withContext('cmd', cmd);
                    lc.debug?.('acquired lock for cvr');
                    client = this.#clients.get(clientID);
                    if (client?.wsID !== wsID) {
                        lc.debug?.('mismatched wsID', client?.wsID, wsID);
                        // Only respond to messages of the currently connected client.
                        // Connections may have been drained or dropped due to an error.
                        return;
                    }
                    if (newClient) {
                        assert(newClient === client, 'newClient must match existing client');
                        checkClientAndCVRVersions(client.version(), cvr.version);
                    }
                    else if (!this.#clients.has(clientID)) {
                        lc.warn?.(`Processing ${cmd} before initConnection was received`);
                    }
                    lc.debug?.(cmd, body);
                    return fn(lc, clientID, body, cvr);
                });
            }
            catch (e) {
                const lc = this.#lc
                    .withContext('clientID', clientID)
                    .withContext('wsID', wsID)
                    .withContext('cmd', cmd);
                lc[getLogLevel(e)]?.(`closing connection with error`, e);
                if (client) {
                    // Ideally, propagate the exception to the client's downstream subscription ...
                    client.fail(e);
                }
                else {
                    // unless the exception happened before the client could be looked up.
                    throw e;
                }
            }
        });
    }
    #getClients(atVersion) {
        const clients = [...this.#clients.values()];
        return atVersion
            ? clients.filter(c => cmpVersions(c.version() ?? EMPTY_CVR_VERSION, atVersion) === 0)
            : clients;
    }
    // Must be called from within #lock.
    #handleConfigUpdate = (lc, clientID, { clientSchema, deleted, desiredQueriesPatch, activeClients, }, cvr) => startAsyncSpan(tracer, 'vs.#patchQueries', async () => {
        const deletedClientIDs = [];
        const deletedClientGroupIDs = [];
        cvr = await this.#updateCVRConfig(lc, cvr, clientID, updater => {
            const { ttlClock } = cvr;
            const patches = [];
            if (clientSchema) {
                updater.setClientSchema(lc, clientSchema);
            }
            // Apply requested patches.
            lc.debug?.(`applying ${desiredQueriesPatch?.length} query patches`);
            if (desiredQueriesPatch?.length) {
                for (const patch of desiredQueriesPatch) {
                    switch (patch.op) {
                        case 'put':
                            patches.push(...updater.putDesiredQueries(clientID, [patch]));
                            break;
                        case 'del':
                            patches.push(...updater.markDesiredQueriesAsInactive(clientID, [patch.hash], ttlClock));
                            break;
                        case 'clear':
                            patches.push(...updater.clearDesiredQueries(clientID));
                            break;
                    }
                }
            }
            const clientIDsToDelete = new Set();
            if (activeClients) {
                // We find all the clients in this client group that are not active.
                const allClientIDs = Object.keys(cvr.clients);
                const activeClientsSet = new Set(activeClients);
                for (const id of allClientIDs) {
                    if (!activeClientsSet.has(id)) {
                        clientIDsToDelete.add(id);
                    }
                }
            }
            if (deleted?.clientIDs?.length) {
                for (const cid of deleted.clientIDs) {
                    assert(cid !== clientID, 'cannot delete self');
                    clientIDsToDelete.add(cid);
                }
            }
            for (const cid of clientIDsToDelete) {
                const patchesDueToClient = updater.deleteClient(cid, ttlClock);
                patches.push(...patchesDueToClient);
                deletedClientIDs.push(cid);
            }
            if (deleted?.clientGroupIDs?.length) {
                lc.debug?.(`ignoring ${deleted.clientGroupIDs.length} deprecated client group deletes`);
            }
            return patches;
        });
        // Send 'deleteClients' ack to the clients.
        if ((deletedClientIDs.length && deleted?.clientIDs?.length) ||
            deletedClientGroupIDs.length) {
            const clients = this.#getClients();
            await Promise.allSettled(clients.map(client => client.sendDeleteClients(lc, deletedClientIDs, deletedClientGroupIDs)));
        }
        this.#scheduleExpireEviction(lc, cvr);
    });
    #scheduleExpireEviction(lc, cvr) {
        const { ttlClock } = cvr;
        this.#stopExpireTimer();
        // first see if there is any inactive query with a ttl.
        const next = nextEvictionTime(cvr);
        if (next === undefined) {
            lc.debug?.('no inactive queries with ttl');
            // no inactive queries with a ttl. Cancel existing timeout if any.
            return;
        }
        // It is common for many queries to be evicted close to the same time, so
        // we add a small delay so we can collapse multiple evictions into a
        // single timer. However, don't add the delay if we're already at the
        // maximum timer limit, as that's not about collapsing.
        const delay = Math.max(TTL_TIMER_HYSTERESIS, Math.min(ttlClockAsNumber(next) -
            ttlClockAsNumber(ttlClock) +
            TTL_TIMER_HYSTERESIS, MAX_TTL_MS));
        lc.debug?.('Scheduling eviction timer to run in ', delay, 'ms');
        this.#expiredQueriesTimer = this.#setTimeout(() => {
            this.#expiredQueriesTimer = 0;
            this.#runInLockWithCVR((lc, cvr) => this.#removeExpiredQueries(lc, cvr)).catch(e => 
            // If an error occurs (e.g. ownership change), propagate the error
            // to the main run() loop via the #stateChanges Subscription.
            this.#stateChanges.fail(e));
        }, delay);
    }
    /**
     * Adds and hydrates pipelines for queries whose results are already
     * recorded in the CVR. Namely:
     *
     * 1. The CVR state version and database version are the same.
     * 2. The transformation hash of the queries equal those in the CVR.
     *
     * Note that by definition, only "got" queries can satisfy condition (2),
     * as desired queries do not have a transformation hash.
     *
     * This is an initialization step that sets up pipeline state without
     * the expensive of loading and diffing CVR row state.
     *
     * This must be called from within the #lock.
     */
    async #hydrateUnchangedQueries(lc, cvr) {
        assert(this.#pipelines.initialized(), 'pipelines must be initialized');
        const dbVersion = this.#pipelines.currentVersion();
        const cvrVersion = cvr.version;
        if (cvrVersion.stateVersion !== dbVersion) {
            lc.info?.(`CVR (${versionToCookie(cvrVersion)}) is behind db ${dbVersion}`);
            return; // hydration needs to be run with the CVR updater.
        }
        const gotQueries = Object.entries(cvr.queries).filter(([_, state]) => state.transformationHash !== undefined);
        const customQueries = new Map();
        const otherQueries = [];
        for (const [, query] of gotQueries) {
            if (query.type !== 'internal' &&
                Object.values(query.clientState).every(({ inactivatedAt }) => inactivatedAt !== undefined)) {
                continue; // No longer desired.
            }
            if (query.type === 'custom') {
                customQueries.set(query.id, query);
            }
            else {
                otherQueries.push(query);
            }
        }
        const transformedQueries = [];
        if (customQueries.size > 0 && !this.#customQueryTransformer) {
            lc.error?.('Custom/named queries were requested but no `ZERO_GET_QUERIES_URL` is configured for Zero Cache.');
        }
        const [_, byOriginalHash] = this.#pipelines.addedQueries();
        if (this.#customQueryTransformer && customQueries.size > 0) {
            const filteredCustomQueries = this.#filterCustomQueries(customQueries.values(), byOriginalHash, undefined);
            const transformedCustomQueries = await this.#customQueryTransformer.transform(this.#getHeaderOptions(this.#queryConfig.forwardCookies), filteredCustomQueries, this.userQueryURL);
            this.#processTransformedCustomQueries(lc, transformedCustomQueries, (q) => transformedQueries.push(q), customQueries);
        }
        for (const q of otherQueries) {
            const transformed = transformAndHashQuery(lc, q.id, q.ast, must(this.#pipelines.currentPermissions()).permissions ?? {
                tables: {},
            }, this.#authData?.decoded, q.type === 'internal');
            if (transformed.transformationHash === q.transformationHash) {
                // only processing unchanged queries here
                transformedQueries.push(transformed);
            }
        }
        for (const { id: queryID, transformationHash, transformedAst, } of transformedQueries) {
            const timer = new TimeSliceTimer();
            let count = 0;
            await startAsyncSpan(tracer, 'vs.#hydrateUnchangedQueries.addQuery', async (span) => {
                span.setAttribute('queryHash', queryID);
                span.setAttribute('transformationHash', transformationHash);
                span.setAttribute('table', transformedAst.table);
                for (const _ of this.#pipelines.addQuery(transformationHash, queryID, transformedAst, await timer.start())) {
                    if (++count % TIME_SLICE_CHECK_SIZE === 0) {
                        if (timer.elapsedLap() > TIME_SLICE_MS) {
                            await timer.yieldProcess();
                        }
                    }
                }
            });
            const elapsed = timer.totalElapsed();
            this.#hydrations.add(1);
            this.#hydrationTime.record(elapsed / 1000);
            this.#addQueryMaterializationServerMetric(transformationHash, elapsed);
            lc.debug?.(`hydrated ${count} rows for ${queryID} (${elapsed} ms)`);
        }
    }
    #processTransformedCustomQueries(lc, transformedCustomQueries, cb, customQueryMap) {
        const errors = [];
        for (const q of transformedCustomQueries) {
            if ('error' in q) {
                lc.error?.(`Error transforming custom query ${q.name}: ${q.error} ${q.details}`);
                errors.push(q);
                continue;
            }
            cb(q);
        }
        this.#sendQueryTransformErrorToClients(customQueryMap, errors);
        return errors;
    }
    #sendQueryTransformErrorToClients(customQueryMap, errors) {
        const errorGroups = new Map();
        for (const err of errors) {
            const q = customQueryMap.get(err.id);
            assert(q, 'got an error that does not map back to a custom query');
            const clientIds = Object.keys(q.clientState);
            for (const clientId of clientIds) {
                const group = errorGroups.get(clientId) ?? [];
                group.push(err);
                errorGroups.set(clientId, group);
            }
        }
        for (const [clientId, errors] of errorGroups) {
            const client = this.#clients.get(clientId);
            if (client) {
                client.sendQueryTransformErrors(errors);
            }
        }
    }
    #addQueryMaterializationServerMetric(transformationHash, elapsed) {
        this.#inspectorDelegate.addMetric('query-materialization-server', elapsed, transformationHash);
    }
    /**
     * Adds and/or removes queries to/from the PipelineDriver to bring it
     * in sync with the set of queries in the CVR (both got and desired).
     * If queries are added, removed, or queried due to a new state version,
     * a new CVR version is created and pokes sent to connected clients.
     *
     * This must be called from within the #lock.
     */
    #syncQueryPipelineSet(lc, cvr) {
        return startAsyncSpan(tracer, 'vs.#syncQueryPipelineSet', async () => {
            assert(this.#pipelines.initialized(), 'pipelines must be initialized (syncQueryPipelineSet)');
            const [hydratedQueries, byOriginalHash] = this.#pipelines.addedQueries();
            // Convert queries to their transformed ast's and hashes
            const hashToIDs = new Map();
            if (this.#ttlClock === undefined) {
                // Get it from the CVR or initialize it to now.
                this.#ttlClock = cvr.ttlClock;
            }
            const now = Date.now();
            const ttlClock = this.#getTTLClock(now);
            // group cvr queries into:
            // 1. custom queries
            // 2. everything else
            // Handle transformation appropriately
            // Then hydrate as `serverQueries`
            const cvrQueryEntires = Object.entries(cvr.queries);
            const customQueries = new Map();
            const otherQueries = [];
            const transformedQueries = [];
            for (const [id, query] of cvrQueryEntires) {
                if (query.type === 'custom') {
                    // This should always match, no?
                    assert(id === query.id, 'custom query id mismatch');
                    customQueries.set(id, query);
                }
                else {
                    otherQueries.push({ id, query });
                }
            }
            for (const { id, query: origQuery } of otherQueries) {
                // This should always match, no?
                assert(id === origQuery.id, 'query id mismatch');
                const transformed = transformAndHashQuery(lc, origQuery.id, origQuery.ast, must(this.#pipelines.currentPermissions()).permissions ?? {
                    tables: {},
                }, this.#authData?.decoded, origQuery.type === 'internal');
                transformedQueries.push({
                    id,
                    origQuery,
                    transformed,
                });
            }
            if (customQueries.size > 0 && !this.#customQueryTransformer) {
                lc.error?.('Custom/named queries were requested but no `ZERO_GET_QUERIES_URL` is configured for Zero Cache.');
            }
            let erroredQueries;
            if (this.#customQueryTransformer && customQueries.size > 0) {
                const filteredCustomQueries = this.#filterCustomQueries(customQueries.values(), byOriginalHash, (origQuery, existing) => {
                    for (const transformed of existing) {
                        transformedQueries.push({
                            id: origQuery.id,
                            origQuery,
                            transformed: {
                                id: origQuery.id,
                                transformationHash: transformed.transformationHash,
                                transformedAst: transformed.transformedAst,
                            },
                        });
                    }
                });
                const transformedCustomQueries = await this.#customQueryTransformer.transform(this.#getHeaderOptions(true), filteredCustomQueries, this.userQueryURL);
                erroredQueries = this.#processTransformedCustomQueries(lc, transformedCustomQueries, (q) => transformedQueries.push({
                    id: q.id,
                    origQuery: must(customQueries.get(q.id)),
                    transformed: q,
                }), customQueries);
            }
            const serverQueries = transformedQueries.map(({ id, origQuery, transformed }) => {
                const ids = hashToIDs.get(transformed.transformationHash);
                if (ids) {
                    ids.push(id);
                }
                else {
                    hashToIDs.set(transformed.transformationHash, [id]);
                }
                return {
                    id,
                    ast: transformed.transformedAst,
                    transformationHash: transformed.transformationHash,
                    remove: expired(ttlClock, origQuery),
                };
            });
            const addQueries = serverQueries.filter(q => !q.remove && !hydratedQueries.has(q.transformationHash));
            const removeQueries = serverQueries.filter(q => q.remove);
            const desiredQueries = new Set(serverQueries.filter(q => !q.remove).map(q => q.transformationHash));
            const unhydrateQueries = [...hydratedQueries].filter(transformationHash => !desiredQueries.has(transformationHash));
            for (const q of addQueries) {
                const orig = cvr.queries[q.id];
                lc.debug?.('ViewSyncer adding query', q.ast, 'transformed from', orig.type === 'custom' ? orig.name : orig.ast);
            }
            // These are queries we need to remove from `desired`, not `got`, because they never transformed.
            if (erroredQueries) {
                for (const q of erroredQueries) {
                    removeQueries.push({
                        id: q.id,
                        transformationHash: undefined,
                    });
                }
            }
            if (addQueries.length > 0 ||
                removeQueries.length > 0 ||
                unhydrateQueries.length > 0) {
                await this.#addAndRemoveQueries(lc, cvr, addQueries, removeQueries, unhydrateQueries, hashToIDs);
            }
            else {
                await this.#catchupClients(lc, cvr);
            }
        });
    }
    // Removes queries from `customQueries` that are already
    // transformed and in the pipelines. We do not want to re-transform
    // a query that has already been transformed. The reason is that
    // we do not want a query that is already running to suddenly flip
    // to error due to re-calling transform.
    #filterCustomQueries(customQueries, byOriginalHash, onExisting) {
        return wrapIterable(customQueries).filter(origQuery => {
            const existing = byOriginalHash.get(origQuery.id);
            if (existing) {
                onExisting?.(origQuery, existing);
                return false;
            }
            return true;
        });
    }
    // This must be called from within the #lock.
    #addAndRemoveQueries(lc, cvr, addQueries, removeQueries, unhydrateQueries, hashToIDs) {
        return startAsyncSpan(tracer, 'vs.#addAndRemoveQueries', async () => {
            assert(addQueries.length > 0 ||
                removeQueries.length > 0 ||
                unhydrateQueries.length > 0, 'Must have queries to add or remove');
            const start = performance.now();
            const stateVersion = this.#pipelines.currentVersion();
            lc = lc.withContext('stateVersion', stateVersion);
            lc.info?.(`hydrating ${addQueries.length} queries`);
            const updater = new CVRQueryDrivenUpdater(this.#cvrStore, cvr, stateVersion, this.#pipelines.replicaVersion);
            // Note: This kicks off background PG queries for CVR data associated with the
            // executed and removed queries.
            const { newVersion, queryPatches } = updater.trackQueries(lc, addQueries, removeQueries);
            const clients = this.#getClients();
            const pokers = startPoke(clients, newVersion, this.#pipelines.currentSchemaVersions());
            for (const patch of queryPatches) {
                await pokers.addPatch(patch);
            }
            // Removing queries is easy. The pipelines are dropped, and the CVR
            // updater handles the updates and pokes.
            for (const q of removeQueries) {
                if (q.transformationHash) {
                    this.#pipelines.removeQuery(q.transformationHash);
                }
                // Remove per-query server metrics when query is deleted
                this.#inspectorDelegate.removeQuery(q.id);
            }
            for (const hash of unhydrateQueries) {
                this.#pipelines.removeQuery(hash);
                // Remove per-query server metrics for unhydrated queries
                const ids = hashToIDs.get(hash);
                if (ids) {
                    for (const id of ids) {
                        this.#inspectorDelegate.removeQuery(id);
                    }
                }
            }
            let totalProcessTime = 0;
            const timer = new TimeSliceTimer();
            const pipelines = this.#pipelines;
            const hydrations = this.#hydrations;
            const hydrationTime = this.#hydrationTime;
            // oxlint-disable-next-line @typescript-eslint/no-this-alias
            const self = this;
            // yield at the very beginning so that the first time slice
            // is properly processed by the time-slice queue.
            await yieldProcess();
            function* generateRowChanges(slowHydrateThreshold) {
                for (const q of addQueries) {
                    lc = lc
                        .withContext('hash', q.id)
                        .withContext('transformationHash', q.transformationHash);
                    lc.debug?.(`adding pipeline for query`, q.ast);
                    yield* pipelines.addQuery(q.transformationHash, q.id, q.ast, timer.startWithoutYielding());
                    const elapsed = timer.stop();
                    totalProcessTime += elapsed;
                    self.#addQueryMaterializationServerMetric(q.transformationHash, elapsed);
                    if (elapsed > slowHydrateThreshold) {
                        lc.warn?.('Slow query materialization', elapsed, q.ast);
                    }
                    manualSpan(tracer, 'vs.addAndConsumeQuery', elapsed, {
                        hash: q.id,
                        transformationHash: q.transformationHash,
                    });
                }
                hydrations.add(1);
                hydrationTime.record(totalProcessTime / 1000);
            }
            // #processChanges does batched de-duping of rows. Wrap all pipelines in
            // a single generator in order to maximize de-duping.
            await this.#processChanges(lc, timer, generateRowChanges(this.#slowHydrateThreshold), updater, pokers, hashToIDs);
            for (const patch of await updater.deleteUnreferencedRows(lc)) {
                await pokers.addPatch(patch);
            }
            // Commit the changes and update the CVR snapshot.
            this.#cvr = await this.#flushUpdater(lc, updater);
            const finalVersion = this.#cvr.version;
            // Before ending the poke, catch up clients that were behind the old CVR.
            await this.#catchupClients(lc, cvr, finalVersion, addQueries.map(q => q.id), pokers);
            // Signal clients to commit.
            await pokers.end(finalVersion);
            const wallTime = performance.now() - start;
            lc.info?.(`finished processing queries (process: ${totalProcessTime} ms, wall: ${wallTime} ms)`);
        });
    }
    /**
     * @param cvr The CVR to which clients should be caught up to. This does
     *     not necessarily need to be the current CVR.
     * @param current The expected current CVR version. Before performing
     *     catchup, the snapshot read will verify that the CVR has not been
     *     concurrently modified. Note that this only needs to be done for
     *     catchup because it is the only time data from the CVR DB is
     *     "exported" without being gated by a CVR flush (which provides
     *     concurrency protection in all other cases).
     *
     *     If unspecified, the version of the `cvr` is used.
     * @param excludeQueryHashes Exclude patches from rows associated with
     *     the specified queries.
     * @param usePokers If specified, sends pokes on existing PokeHandlers,
     *     in which case the caller is responsible for sending the `pokeEnd`
     *     messages. If unspecified, the pokes will be started and ended
     *     using the version from the supplied `cvr`.
     */
    // Must be called within #lock
    #catchupClients(lc, cvr, current, excludeQueryHashes = [], usePokers) {
        return startAsyncSpan(tracer, 'vs.#catchupClients', async (span) => {
            current ??= cvr.version;
            const clients = this.#getClients();
            const pokers = usePokers ??
                startPoke(clients, cvr.version, this.#pipelines.currentSchemaVersions());
            span.setAttribute('numClients', clients.length);
            const catchupFrom = clients
                .map(c => c.version())
                .reduce((a, b) => (cmpVersions(a, b) < 0 ? a : b), cvr.version);
            // This is an AsyncGenerator which won't execute until awaited.
            const rowPatches = this.#cvrStore.catchupRowPatches(lc, catchupFrom, cvr, current, excludeQueryHashes);
            // This is a plain async function that kicks off immediately.
            const configPatches = this.#cvrStore.catchupConfigPatches(lc, catchupFrom, cvr, current);
            // await the rowPatches first so that the AsyncGenerator kicks off.
            let rowPatchCount = 0;
            for await (const rows of rowPatches) {
                for (const row of rows) {
                    const { schema, table } = row;
                    const rowKey = row.rowKey;
                    const toVersion = versionFromString(row.patchVersion);
                    const id = { schema, table, rowKey };
                    let patch;
                    if (!row.refCounts) {
                        patch = { type: 'row', op: 'del', id };
                    }
                    else {
                        const row = must(this.#pipelines.getRow(table, rowKey), `Missing row ${table}:${stringify(rowKey)}`);
                        const { contents } = contentsAndVersion(row);
                        patch = { type: 'row', op: 'put', id, contents };
                    }
                    const patchToVersion = { patch, toVersion };
                    await pokers.addPatch(patchToVersion);
                    rowPatchCount++;
                }
            }
            span.setAttribute('rowPatchCount', rowPatchCount);
            if (rowPatchCount) {
                lc.debug?.(`sent ${rowPatchCount} row patches`);
            }
            // Then await the config patches which were fetched in parallel.
            for (const patch of await configPatches) {
                await pokers.addPatch(patch);
            }
            if (!usePokers) {
                await pokers.end(cvr.version);
            }
        });
    }
    #processChanges(lc, timer, changes, updater, pokers, hashToIDs) {
        return startAsyncSpan(tracer, 'vs.#processChanges', async () => {
            const start = performance.now();
            const rows = new CustomKeyMap(rowIDString);
            let total = 0;
            const processBatch = () => startAsyncSpan(tracer, 'processBatch', async () => {
                const wallElapsed = performance.now() - start;
                total += rows.size;
                lc.debug?.(`processing ${rows.size} (of ${total}) rows (${wallElapsed} ms)`);
                const patches = await updater.received(lc, rows);
                for (const patch of patches) {
                    await pokers.addPatch(patch);
                }
                rows.clear();
            });
            await startAsyncSpan(tracer, 'loopingChanges', async (span) => {
                for (const change of changes) {
                    const { type, queryHash: transformationHash, table, rowKey, row, } = change;
                    const queryIDs = must(hashToIDs.get(transformationHash), 'could not find the original hash for the transformation hash');
                    const rowID = { schema: '', table, rowKey: rowKey };
                    let parsedRow = rows.get(rowID);
                    if (!parsedRow) {
                        parsedRow = { refCounts: {} };
                        rows.set(rowID, parsedRow);
                    }
                    queryIDs.forEach(hash => (parsedRow.refCounts[hash] ??= 0));
                    const updateVersion = (row) => {
                        // IVM can output multiple versions of a row as it goes through its
                        // intermediate stages. Always update the version and contents;
                        // the last version will reflect the final state.
                        const { version, contents } = contentsAndVersion(row);
                        parsedRow.version = version;
                        parsedRow.contents = contents;
                    };
                    switch (type) {
                        case 'add':
                            updateVersion(row);
                            queryIDs.forEach(hash => parsedRow.refCounts[hash]++);
                            break;
                        case 'edit':
                            updateVersion(row);
                            // No update to refCounts.
                            break;
                        case 'remove':
                            queryIDs.forEach(hash => parsedRow.refCounts[hash]--);
                            break;
                        default:
                            unreachable(type);
                    }
                    if (rows.size % CURSOR_PAGE_SIZE === 0) {
                        await processBatch();
                    }
                    if (rows.size % TIME_SLICE_CHECK_SIZE === 0) {
                        if (timer.elapsedLap() > TIME_SLICE_MS) {
                            await timer.yieldProcess();
                        }
                    }
                }
                if (rows.size) {
                    await processBatch();
                }
                span.setAttribute('totalRows', total);
            });
        });
    }
    /**
     * Advance to the current snapshot of the replica and apply / send
     * changes.
     *
     * Must be called from within the #lock.
     *
     * Returns false if the advancement failed due to a schema change.
     */
    #advancePipelines(lc, cvr) {
        return startAsyncSpan(tracer, 'vs.#advancePipelines', async () => {
            assert(this.#pipelines.initialized(), 'pipelines must be initialized (advancePipelines');
            const start = performance.now();
            const timer = new TimeSliceTimer();
            const { version, numChanges, changes } = this.#pipelines.advance(timer);
            lc = lc.withContext('newVersion', version);
            // Probably need a new updater type. CVRAdvancementUpdater?
            const updater = new CVRQueryDrivenUpdater(this.#cvrStore, cvr, version, this.#pipelines.replicaVersion);
            // Only poke clients that are at the cvr.version. New clients that
            // are behind need to first be caught up when their initConnection
            // message is processed (and #syncQueryPipelines is called).
            const pokers = startPoke(this.#getClients(cvr.version), updater.updatedVersion(), this.#pipelines.currentSchemaVersions());
            lc.debug?.(`applying ${numChanges} to advance to ${version}`);
            const hashToIDs = createHashToIDs(cvr);
            try {
                await this.#processChanges(lc, await timer.start(), changes, updater, pokers, hashToIDs);
            }
            catch (e) {
                if (e instanceof ResetPipelinesSignal) {
                    await pokers.cancel();
                    return e;
                }
                throw e;
            }
            // Commit the changes and update the CVR snapshot.
            this.#cvr = await this.#flushUpdater(lc, updater);
            const finalVersion = this.#cvr.version;
            // Signal clients to commit.
            await pokers.end(finalVersion);
            const elapsed = performance.now() - start;
            lc.info?.(`finished processing advancement of ${numChanges} changes (${elapsed} ms)`);
            this.#transactionAdvanceTime.record(elapsed / 1000);
            return 'success';
        });
    }
    inspect(context, msg) {
        return this.#runInLockForClient(context, msg, this.#handleInspect);
    }
    // oxlint-disable-next-line require-await
    #handleInspect = async (lc, clientID, body, cvr) => {
        const client = must(this.#clients.get(clientID));
        return handleInspect(lc, body, cvr, client, this.#inspectorDelegate, this.id, this.#cvrStore, this.#config, this.#getHeaderOptions(this.#queryConfig.forwardCookies ?? false), this.userQueryURL);
    };
    stop() {
        this.#lc.info?.('stopping view syncer');
        this.#initialized.reject('shut down before initialization completed');
        this.#stateChanges.cancel();
        return this.#stopped.promise;
    }
    #cleanup(err) {
        this.#stopTTLClockInterval();
        this.#stopExpireTimer();
        this.#pipelines.destroy();
        for (const client of this.#clients.values()) {
            if (err) {
                client.fail(err);
            }
            else {
                client.close(`closed clientGroupID=${this.id}`);
            }
        }
    }
    /**
     * Test helper: Manually mark initialization as complete.
     * This should only be used in tests that don't call initConnection().
     */
    markInitialized() {
        this.#initialized.resolve('initialized');
    }
}
// Update CVR after every 10000 rows.
const CURSOR_PAGE_SIZE = 10000;
// Check the elapsed time every 100 rows.
const TIME_SLICE_CHECK_SIZE = 100;
// Yield the process after churning for > 500ms.
const TIME_SLICE_MS = 500;
function createHashToIDs(cvr) {
    const hashToIDs = new Map();
    for (const { id, transformationHash } of Object.values(cvr.queries)) {
        if (!transformationHash) {
            continue;
        }
        if (hashToIDs.has(transformationHash)) {
            must(hashToIDs.get(transformationHash)).push(id);
        }
        else {
            hashToIDs.set(transformationHash, [id]);
        }
    }
    return hashToIDs;
}
// A global Lock acts as a queue to run a single IVM time slice per iteration
// of the node event loop, thus bounding I/O delay to the duration of a single
// time slice.
//
// Refresher:
// https://nodejs.org/en/learn/asynchronous-work/event-loop-timers-and-nexttick#phases-overview
//
// Note that recursive use of setImmediate() (i.e. calling setImmediate() from
// within a setImmediate() callback), results in enqueuing the latter
// callback in the *next* event loop iteration, as documented in:
// https://nodejs.org/api/timers.html#setimmediatecallback-args
//
// This effectively achieves the desired one-per-event-loop-iteration behavior.
const timeSliceQueue = new Lock();
function yieldProcess() {
    return timeSliceQueue.withLock(() => new Promise(setImmediate));
}
function contentsAndVersion(row) {
    const { [ZERO_VERSION_COLUMN_NAME]: version, ...contents } = row;
    if (typeof version !== 'string' || version.length === 0) {
        throw new Error(`Invalid _0_version in ${stringify(row)}`);
    }
    return { contents, version };
}
const NEW_CVR_VERSION = { stateVersion: '00' };
function checkClientAndCVRVersions(client, cvr) {
    if (cmpVersions(cvr, NEW_CVR_VERSION) === 0 &&
        cmpVersions(client, NEW_CVR_VERSION) > 0) {
        // CVR is empty but client is not.
        throw new ErrorForClient({
            kind: ErrorKind.ClientNotFound,
            message: 'Client not found',
        });
    }
    if (cmpVersions(client, cvr) > 0) {
        // Client is ahead of a non-empty CVR.
        throw new ErrorForClient({
            kind: ErrorKind.InvalidConnectionRequestBaseCookie,
            message: `CVR is at version ${versionString(cvr)}`,
        });
    }
}
export function pickToken(lc, previousToken, newToken) {
    if (previousToken === undefined) {
        lc.debug?.(`No previous token, using new token`);
        return newToken;
    }
    if (newToken) {
        if (previousToken.decoded.sub !== newToken.decoded.sub) {
            throw new ErrorForClient({
                kind: ErrorKind.Unauthorized,
                message: 'The user id in the new token does not match the previous token. Client groups are pinned to a single user.',
            });
        }
        if (previousToken.decoded.iat === undefined) {
            lc.debug?.(`No issued at time for the existing token, using new token`);
            // No issued at time for the existing token? We take the most recently received token.
            return newToken;
        }
        if (newToken.decoded.iat === undefined) {
            throw new ErrorForClient({
                kind: ErrorKind.Unauthorized,
                message: 'The new token does not have an issued at time but the prior token does. Tokens for a client group must either all have issued at times or all not have issued at times',
            });
        }
        // The new token is newer, so we take it.
        if (previousToken.decoded.iat < newToken.decoded.iat) {
            lc.debug?.(`New token is newer, using it`);
            return newToken;
        }
        // if the new token is older or the same, we keep the existing token.
        lc.debug?.(`New token is older or the same, using existing token`);
        return previousToken;
    }
    // previousToken !== undefined but newToken is undefined
    throw new ErrorForClient({
        kind: ErrorKind.Unauthorized,
        message: 'No token provided. An unauthenticated client cannot connect to an authenticated client group.',
    });
}
/**
 * A query must be expired for all clients in order to be considered
 * expired.
 */
function expired(ttlClock, q) {
    if (q.type === 'internal') {
        return false;
    }
    for (const clientState of Object.values(q.clientState)) {
        const { ttl, inactivatedAt } = clientState;
        if (inactivatedAt === undefined) {
            return false;
        }
        const clampedTTL = clampTTL(ttl);
        if (ttlClockAsNumber(inactivatedAt) + clampedTTL >
            ttlClockAsNumber(ttlClock)) {
            return false;
        }
    }
    return true;
}
function hasExpiredQueries(cvr) {
    const { ttlClock } = cvr;
    for (const q of Object.values(cvr.queries)) {
        if (expired(ttlClock, q)) {
            return true;
        }
    }
    return false;
}
export class TimeSliceTimer {
    #total = 0;
    #start = 0;
    async start() {
        // yield at the very beginning so that the first time slice
        // is properly processed by the time-slice queue.
        await yieldProcess();
        return this.startWithoutYielding();
    }
    startWithoutYielding() {
        this.#total = 0;
        this.#startLap();
        return this;
    }
    async yieldProcess() {
        this.#stopLap();
        await yieldProcess();
        this.#startLap();
    }
    #startLap() {
        assert(this.#start === 0, 'already running');
        this.#start = performance.now();
    }
    elapsedLap() {
        assert(this.#start !== 0, 'not running');
        return performance.now() - this.#start;
    }
    #stopLap() {
        assert(this.#start !== 0, 'not running');
        this.#total += performance.now() - this.#start;
        this.#start = 0;
    }
    /** @returns the total elapsed time */
    stop() {
        this.#stopLap();
        return this.#total;
    }
    /**
     * @returns the elapsed time. This can be called while the Timer is running
     *          or after it has been stopped.
     */
    totalElapsed() {
        return this.#start === 0
            ? this.#total
            : this.#total + performance.now() - this.#start;
    }
}
//# sourceMappingURL=view-syncer.js.map