UNPKG

@clickup/ent-framework

Version:

A PostgreSQL graph-database-alike library with microsharding and row-level security

563 lines (515 loc) 17.6 kB
import { inspect } from "util"; import delay from "delay"; import { Memoize } from "fast-typescript-memoize"; import compact from "lodash/compact"; import type { Client } from "../abstract/Client"; import type { Handler } from "../abstract/Loader"; import { Loader } from "../abstract/Loader"; import type { QueryAnnotation } from "../abstract/QueryAnnotation"; import type { Shard } from "../abstract/Shard"; import { MASTER, STALE_REPLICA } from "../abstract/Shard"; import { Timeline } from "../abstract/Timeline"; import { minifyStack } from "../internal/misc"; import { VCCaches } from "./VCCaches"; import type { VCFlavor } from "./VCFlavor"; import { VCWithStacks } from "./VCFlavor"; import { VCTrace } from "./VCTrace"; /** * Guest VC: has minimum permissions. Typically if the user is not logged in, * this VC is used. */ export const GUEST_ID = "guest"; /** * Temporary "omniscient" VC. Any Ent can be loaded with it, but this VC is * replaced with lower-pri VC as soon as possible. E.g. when some Ent is loaded * with omni VC, its ent.vc is assigned to either this Ent's "owner" VC * (accessible via VC pointing field) or, if not detected, to guest VC. */ export const OMNI_ID = "omni"; /** * Useful for debugging, to identify unique VC objects. */ let instanceNumber = 0; /** * VC - Viewer Context. * * VC is set per HTTP request (or per worker job) in each Ent and represents the * person who is about to run some database operation. It can represent a user, * or a guest, or a bot observing that Ent. * * Depending on the Ent's Configuration object and privacy rules, it may allow * the user to load/insert/update/etc. or to traverse to related objects. */ export class VC { // // WARNING! // // DO NOT use ES2020 #-private properties for VC, use only native TS private // properties. This is because VC is often times cloned by core libraries // (e.g. Apollo GraphQL context could be a VC, or Express Request user is VC // etc.). ES2020 #-private properties are simulated via WeakMap during // transpilation which requires a call to VC's constructor. And while cloning // via Object.create()/Object.setPrototypeOf(), the constructor is not called. // private annotationCache?: QueryAnnotation; private caches: VCCaches<Function | symbol, unknown> | undefined = undefined; private instanceNumber = (instanceNumber++).toString(); // string makes it visible in Chrome memory dump profiler /** * Please please don't call this method except one or two core places. The * idea is that we create an "origin" VC once and then derive all other VCs * from it (possibly upgrading or downgrading permissions, controlling * master/replica read policy etc.). It's also good to trace the entire chain * of calls and reasons, why some object was accessed. */ static createGuestPleaseDoNotUseCreationPointsMustBeLimited({ trace, cachesExpirationMs, }: { trace?: string; cachesExpirationMs?: number; } = {}): VC { return new VC( new VCTrace(trace), GUEST_ID, null, new Map(), new Map(), { heartbeat: async () => {}, delay }, true, cachesExpirationMs ?? 0, ); } /** * This is to show VCs in console.log() and inspect() nicely. */ [inspect.custom](): string { return `<${this.toString()}>`; } /** * Some IDs are cached in VC (e.g. is this ID readable? is it writable? is * this VC an admin VC?). Also, people may define their own VC-local caches. */ cache<TInstance>(Class: { new (vc: VC): TInstance }): TInstance; /** * Same as the above overload, but allows to use a custom creating function. * This is useful when e.g. cached values are async-created. */ cache<TInstance>(tag: symbol, creator: (vc: VC) => TInstance): TInstance; // The actual implementation of the above overloads. cache<TInstance>( ClassOrTag: { new (vc: VC): TInstance } | symbol, creator?: (vc: VC) => TInstance, ): TInstance { this.caches ??= new VCCaches(this.cachesExpirationMs); let cache = this.caches.get(ClassOrTag) as TInstance | undefined; if (!cache) { cache = typeof ClassOrTag === "function" ? new ClassOrTag(this) : creator!(this); this.caches.set(ClassOrTag, cache); } return cache; } /** * Returns a cached instance of Loader whose actual code is defined in * HandlerClass. In case there is no such Loader yet, creates it. */ loader<TLoadArgs extends unknown[], TReturn>(HandlerClass: { new (vc: VC): Handler<TLoadArgs, TReturn>; $loader?: symbol; }): Loader<TLoadArgs, TReturn> { let symbol = HandlerClass.$loader; if (!symbol) { symbol = HandlerClass.$loader = Symbol(HandlerClass.name); } return this.cache(symbol, () => new Loader(() => new HandlerClass(this))); } /** * Returns Shard+schemaName timeline which tracks replica staleness for the * particular schema name (most likely, table). */ timeline(shard: Shard<Client>, schemaName: string): Timeline { const key = shard.no + ":" + schemaName; let timeline = this.timelines.get(key); if (timeline === undefined) { timeline = new Timeline(); this.timelines.set(key, timeline); } return timeline; } /** * Serializes Shard timelines (master WAL positions) to a string format. The * method always returns a value which is compatible to * deserializeTimelines() input. */ serializeTimelines(): string | undefined { const timelines: Record<string, string> = {}; for (const [key, timeline] of this.timelines) { const timelineStr = timeline.serialize(); if (timelineStr) { timelines[key] = timelineStr; } } // Not a single write has been done in this VC; skip serialization. if (Object.keys(timelines).length === 0) { return undefined; } return JSON.stringify(timelines); } /** * Restores all replication timelines in the VC based on the serialized info * provided. Returns the new VC derived from the current one, but with empty * caches. * * This method has a side effect of changing the timelines of the current VC * (and actually all parent VCs), because it reflects the changes in the * global DB state as seen by the current VC's principal. It restores * previously serialized timelines to the existing VC and all its parent VCs * which share the same principal. (The latter happens, because * `this.timelines` map is passed by reference to all derived VCs starting * from the one which sets principal; see `new VC(...)` clauses all around and * toLowerInternal() logic.) The timelines are merged according to WAL * positions (larger WAL positions win). */ deserializeTimelines(...dataStrs: ReadonlyArray<string | undefined>): VC { let deserialized = false; for (const dataStr of dataStrs) { if (dataStr) { const data = JSON.parse(dataStr) as Record<string, string>; for (const [key, timelineStr] of Object.entries(data)) { const oldTimeline = this.timelines.get(key) ?? null; this.timelines.set( key, Timeline.deserialize(timelineStr, oldTimeline), ); deserialized = true; } } } return deserialized ? this.withEmptyCache() : this; } /** * Returns a new VC derived from the current one, but with empty cache. */ withEmptyCache(): VC { return new VC( this.trace, this.principal, this.freshness, this.timelines, this.flavors, this.heartbeater, this.isRoot, this.cachesExpirationMs, ); } /** * Returns a new VC derived from the current one, but with master freshness. * Master freshness is inherited by ent.vc after an Ent is loaded. */ withTransitiveMasterFreshness(): VC { if (this.freshness === MASTER) { return this; } return new VC( this.trace, this.principal, MASTER, this.timelines, this.flavors, this.heartbeater, this.isRoot, this.cachesExpirationMs, ); } /** * Returns a new VC derived from the current one, but which forces an Ent to * be loaded always from replica. Freshness is NOT inherited by Ents (not * transitive): e.g. if an Ent is loaded with STALE_REPLICA freshness, its * ent.vc will have the DEFAULT freshness. * * Also, if an Ent is inserted with a VC of STALE_REPLICA freshness, its VC * won't remember it, so next immediate reads will go to a replica and not to * the master. */ withOneTimeStaleReplica(): VC { if (this.freshness === STALE_REPLICA) { return this; } return new VC( this.trace, this.principal, STALE_REPLICA, this.timelines, this.flavors, this.heartbeater, this.isRoot, this.cachesExpirationMs, ); } /** * Creates a new VC with default freshness (i.e. not sticky to master or * replica, auto-detected on request). Generally, it's not a good idea to use * this derivation since we lose some bit of internal knowledge from the past * history of the VC, but for e.g. tests or benchmarks, it's fine. */ withDefaultFreshness(): VC { if (this.freshness === null) { return this; } return new VC( this.trace, this.principal, null, this.timelines, this.flavors, this.heartbeater, this.isRoot, this.cachesExpirationMs, ); } /** * Returns a new VC derived from the current one adding some more flavors to * it. If no flavors were added, returns the same VC (`this`). */ withFlavor(prepend: "prepend", ...flavors: Array<VCFlavor | undefined>): this; withFlavor(...flavors: Array<VCFlavor | undefined>): this; withFlavor(...args: unknown[]): VC { const prepend = args[0] === "prepend" ? args.shift() : undefined; const pairs = (args as Array<VCFlavor | undefined>) .filter((flavor): flavor is VCFlavor => flavor !== undefined) .map((flavor) => [flavor.constructor, flavor] as const); return pairs.length > 0 ? new VC( this.trace, this.principal, this.freshness, this.timelines, new Map( prepend === "prepend" ? [ ...pairs, ...[...this.flavors].filter(([cons]) => pairs.every(([newCons]) => cons !== newCons), ), ] : [ ...this.flavors, // Keys in pairs override the previous flavors, do we don't // need to filter here as above (performance). ...pairs, ], ), this.heartbeater, this.isRoot, this.cachesExpirationMs, ) : this; } /** * Derives the VC with new trace ID. */ withNewTrace(trace: string | undefined): VC { return new VC( new VCTrace(trace), this.principal, this.freshness, this.timelines, this.flavors, this.heartbeater, this.isRoot, this.cachesExpirationMs, ); } /** * Derives the VC with the provided heartbeater injected. */ withHeartbeater(heartbeater: VC["heartbeater"]): VC { return new VC( this.trace, this.principal, this.freshness, this.timelines, this.flavors, heartbeater, this.isRoot, this.cachesExpirationMs, ); } /** * Creates a new VC upgraded to omni permissions. This VC will not * be placed to some Ent's ent.vc property; instead, it will be * automatically downgraded to either the owning VC of this Ent or * to a guest VC (see Ent.ts). */ @Memoize() toOmniDangerous(): VC { return new VC( this.trace, OMNI_ID, this.freshness, this.timelines, this.flavors, this.heartbeater, this.isRoot, this.cachesExpirationMs, ); } /** * Creates a new VC downgraded to guest permissions. */ @Memoize() public toGuest(): VC { return new VC( this.trace, GUEST_ID, this.freshness, this.timelines, this.flavors, this.heartbeater, this.isRoot, this.cachesExpirationMs, ); } /** * Checks if it's an omni VC. */ isOmni(): boolean { return this.principal === OMNI_ID; } /** * Checks if it's a guest VC. */ isGuest(): boolean { return this.principal === GUEST_ID; } /** * Checks if it's a regular user (i.e. owning) VC. */ isLoggedIn(): boolean { return !this.isOmni() && !this.isGuest(); } /** * Returns VC's flavor of the particular type. */ flavor<TFlavor extends VCFlavor>( flavor: new (...args: never[]) => TFlavor, ): TFlavor | null { return (this.flavors.get(flavor) as TFlavor | undefined) ?? null; } /** * Used for debugging purposes. */ toString(withInstanceNumber = false): string { const flavorsStr = compact([ withInstanceNumber && this.instanceNumber, ...[...this.flavors.values()].map((flavor) => flavor.toDebugString()), ]).join(","); return ( `vc:${this.principal}` + (flavorsStr ? `(${flavorsStr})` : "") + (this.freshness === MASTER ? ":master" : this.freshness === STALE_REPLICA ? ":stale_replica" : "") ); } /** * Returns a debug annotation of this VC. */ toAnnotation(): QueryAnnotation { if (!this.annotationCache) { this.annotationCache = { // DON'T alter trace here anyhow, or it would break the debugging chain. trace: this.trace.trace, debugStack: "", // vc.toString() returns a textual VC with all flavors mixed in vc: this.toString(), whyClient: undefined, attempt: 0, }; } if (this.flavor(VCWithStacks)) { // This is expensive, only enabled explicitly for this flavor. return { ...this.annotationCache, debugStack: minifyStack(Error().stack?.toString() ?? "", 1), }; } return this.annotationCache; } /** * Used internally by Ent framework to lower permissions of an injected VC. * For guest, principal === null. * - freshness is always reset to default one it VC is demoted * - isRoot is changed to false once a root VC is switched to a per-user VC */ @Memoize() public toLowerInternal(principal: string | null): VC { const newPrincipal = principal ? principal.toString() : GUEST_ID; if (this.principal === newPrincipal && this.freshness !== STALE_REPLICA) { // Speed optimization (this happens most of the time): a VC is already // user-owned, and freshness is default or MASTER. return this; } const switchesToPrincipalFirstTime = this.isRoot && newPrincipal !== GUEST_ID && newPrincipal !== OMNI_ID; const newIsRoot = this.isRoot && !switchesToPrincipalFirstTime; // Create an independent timelines map only when we switch to a non-root VC // the 1st time (e.g. in the beginning of HTTP connection). const newTimelines = switchesToPrincipalFirstTime ? Timeline.cloneMap(this.timelines) : this.timelines; // A special case: demote STALE_REPLICA freshness to default (it's not // transitive and applies only till the next derivation). const newFreshness = this.freshness === STALE_REPLICA ? null : this.freshness; // Something has changed (most commonly omni->principal or omni->guest). return new VC( this.trace, newPrincipal, newFreshness, newTimelines, this.flavors, this.heartbeater, newIsRoot, this.cachesExpirationMs, ); } /** * Private constructor disallows inheritance and manual object creation. */ private constructor( /** Trace information to quickly find all the requests done by this VC in * debug logs. Trace is inherited once VC is derived. */ private readonly trace: VCTrace, /** A principal (typically user ID) represented by this VC. */ public readonly principal: string, /** Allows to set VC to always use either a master or a replica DB. E.g. if * freshness=MASTER, then all the timeline data is ignored, and all the * requests are sent to master. */ public readonly freshness: null | typeof MASTER | typeof STALE_REPLICA, /** Replication WAL position per Shard & Ent. Used to make decisions, * should a request be sent to a replica or to the master. */ private timelines: Map<string, Timeline>, /** Sticky objects attached to the VC (and inherited when deriving). */ private flavors: ReadonlyMap<Function, VCFlavor>, /** The heartbeat callback is called before each primitive operation. It * plays the similar role as AbortController: when called, it may throw * sometimes (signalled externally). Delay callback can also be passed since * it's pretty common use case to wait for some time and be aborted on a * heartbeat exception. */ public readonly heartbeater: { readonly heartbeat: () => Promise<void>; readonly delay: (ms: number) => Promise<void>; }, /** If true, it's the initial "root" VC which is not yet derived to any * user's VC. */ private isRoot: boolean, /** If nonzero, VC#cache() will return the values which will be auto-removed * when VC#cache() hasn't been called for more than this time. */ private cachesExpirationMs: number, ) {} }