All files / json-crdt-repo/src/session EditSessionFactory.ts

100% Statements 87/87
84.61% Branches 44/52
76.92% Functions 10/13
100% Lines 78/78

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 2349x   9x 9x             9x 86x 86x   86x     239x         128x 128x 34x 34x         80x 80x 80x 79x   80x                         76x 76x 63x 63x 63x 63x 63x 36x 36x 36x     63x 27x         63x 63x 63x                             51x 51x 31x 31x 31x 1x 1x 1x   30x 30x 30x 30x   30x         30x 30x 30x 30x 13x 13x 13x 2x     2x 2x       13x 13x     17x     17x 17x 17x 12x 12x 12x     5x 4x 4x 4x 4x     8x     8x   2x 1x     5x 4x     1x         10x   2x                                                                                                                                                    
import {Model, type NodeBuilder} from 'json-joy/lib/json-crdt';
import type {BlockId, LocalRepo} from '../local/types';
import {EditSession} from './EditSession';
import {timeout} from 'thingies/lib/timeout';
 
export interface EditSessionFactoryOpts {
  readonly sid: number;
  readonly repo: LocalRepo;
}
 
export class EditSessionFactory {
  protected readonly cache = new Map<string, EditSession>();
  protected readonly loading = new Map<string, Promise<EditSession>>();
 
  constructor(protected readonly opts: EditSessionFactoryOpts) {}
 
  protected key(id: BlockId): string {
    return id.join('\x00');
  }
 
  /** Increment refcount on an existing cached session, if any. */
  protected acquire(id: BlockId): EditSession | undefined {
    const session = this.cache.get(this.key(id));
    if (!session) return undefined;
    session.acquire();
    return session;
  }
 
  /** Cache a freshly created session under its block ID and hook its teardown to remove the cache entry. */
  protected register(id: BlockId, session: EditSession): EditSession {
    const k = this.key(id);
    this.cache.set(k, session);
    session.onTeardown = () => {
      Eif (this.cache.get(k) === session) this.cache.delete(k);
    };
    return session;
  }
 
  /**
   * Creates a new editing session synchronously (immediately). If the block
   * with a given ID already exists, it asynchronously synchronizes the local
   * and remote state.
   *
   * If a session for this block ID already exists in this factory, the
   * existing instance is returned and its refcount is incremented. The
   * session is fully torn down only when every caller has called `dispose()`.
   */
  public make(opts: EditSessionMakeOpts): {session: EditSession; sync?: Promise<void>} {
    const existing = this.acquire(opts.id);
    if (existing) return {session: existing};
    const {id, schema, pull = true} = opts;
    const factoryOpts = this.opts;
    const model = Model.create(void 0, factoryOpts.sid);
    const session = new EditSession(factoryOpts.repo, id, model, undefined, opts.session);
    if (schema) {
      const sessionModel = session.model;
      sessionModel.setSchema(schema);
      sessionModel.api.flush();
    }
    let sync: Promise<void> | undefined;
    if (pull && !session.log.patches.size()) {
      sync = session
        .sync()
        .then(() => {})
        .catch(() => {});
    }
    session.log.end.api.autoFlush();
    this.register(id, session);
    return {session, sync};
  }
 
  /**
   * Load block from the local repo. Creates a new editing session
   * asynchronously from an existing local block.
   *
   * It is also possible to block on remote state check in case the block does
   * not exist locally. When `pull` is set, it will also refresh the latest
   * state from the remote in the background after returning local state.
   *
   * If a session for this block ID already exists (or is being loaded), the
   * existing instance is returned and its refcount is incremented.
   */
  public async load(opts: EditSessionLoadOpts): Promise<EditSession> {
    const existing = this.acquire(opts.id);
    if (existing) return existing;
    const k = this.key(opts.id);
    const inflight = this.loading.get(k);
    if (inflight) {
      await inflight.catch(() => {});
      const reused = this.acquire(opts.id);
      Eif (reused) return reused;
    }
    const promise = this._load(opts);
    this.loading.set(k, promise);
    try {
      return await promise;
    } finally {
      this.loading.delete(k);
    }
  }
 
  protected async _load(opts: EditSessionLoadOpts): Promise<EditSession> {
    const id = opts.id;
    const repo = this.opts.repo;
    try {
      const {model, cursor} = await repo.get({id});
      const session = new EditSession(repo, id, model, cursor, opts.session);
      session.log.end.api.autoFlush();
      if (opts.pull) {
        void repo
          .pull(id)
          .then(async ({cursor}) => {
            session.cursor = cursor;
            await session.load();
          })
          .catch(() => {});
      }
      this.register(id, session);
      return session;
    } catch (error) {
      const errorCode =
        !!error && typeof error === 'object'
          ? (error as Record<string, unknown>).code || (error as Record<string, unknown>).message || ''
          : '';
      Eif (errorCode === 'NOT_FOUND') {
        const remote = opts.remote;
        if (remote) {
          const timeoutMs = remote.timeout;
          try {
            const {model, cursor} = await (typeof timeoutMs === 'number'
              ? timeout(timeoutMs, repo.pull(id))
              : repo.pull(id));
            if (remote.throwIf === 'exists') throw new Error('EXISTS');
            const session = new EditSession(repo, id, model, cursor, opts.session);
            session.log.end.api.autoFlush();
            this.register(id, session);
            return session;
          } catch (error) {
            const errorCode =
              !!error && typeof error === 'object'
                ? (error as Record<string, unknown>).code || (error as Record<string, unknown>).message || ''
                : '';
            switch (errorCode) {
              case 'TIMEOUT': {
                if (!opts.make) throw error;
                break;
              }
              case 'NOT_FOUND': {
                if (remote.throwIf === 'missing') throw error;
                break;
              }
              default: {
                throw error;
              }
            }
          }
        }
        if (opts.make) return this.make({session: opts.session, ...opts.make, id}).session;
      }
      throw error;
    }
  }
}
 
/**
 * Constructs a new editing session synchronously.
 */
export interface EditSessionMakeOpts {
  /** Block ID. */
  id: BlockId;
 
  /** The new block schema, if any. */
  schema?: NodeBuilder;
 
  /**
   * Whether to asynchronously pull for any existing local block state, if a
   * block with the same ID already exists. Defaults to `true`.
   */
  pull?: boolean;
 
  /**
   * Internal unique session ID.
   */
  session?: number;
}
 
/**
 * Constructs and editing session asynchronously from an existing block. In
 * case the block does not exist, it is possible to create one or throw an
 * error.
 */
export interface EditSessionLoadOpts {
  /** Block ID. */
  id: BlockId;
 
  /**
   * If specified, will create a new block, if one does not already exist. Will
   * use these `make` options and provide them to the `make()` call.
   */
  make?: Omit<EditSessionMakeOpts, 'id'>;
 
  // /** The new block schema, if any. */
  // schema?: NodeBuilder;
 
  /**
   * Internal unique session ID.
   */
  session?: number;
 
  /**
   * Whether to refresh the latest state from the remote in the background
   * after loading an existing local block.
   */
  pull?: boolean;
 
  remote?: {
    /**
     * Time in milliseconds to wait for the remote to respond. If the remote
     * does not respond in time, the call will proceed with the local state.
     *
     * If upsert `make` option is not provided, the call will throw a "TIMEOUT"
     * error.
     */
    timeout?: number;
 
    /**
     * Defaults to an empty string. Otherwise, if "missing", will throw a
     * "NOT_FOUND" error if the block does not exist remotely. If "exists", will
     * a "CONFLICT" error if the block exists remotely.
     */
    throwIf?: '' | 'missing' | 'exists';
  };
}