@clickup/ent-framework
Version:
A PostgreSQL graph-database-alike library with microsharding and row-level security
563 lines (515 loc) • 17.6 kB
text/typescript
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).
*/
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.
*/
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
*/
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,
) {}
}