@clickup/ent-framework
Version:
A PostgreSQL graph-database-alike library with microsharding and row-level security
384 lines • 16.8 kB
JavaScript
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.VC = exports.OMNI_ID = exports.GUEST_ID = void 0;
const util_1 = require("util");
const delay_1 = __importDefault(require("delay"));
const fast_typescript_memoize_1 = require("fast-typescript-memoize");
const compact_1 = __importDefault(require("lodash/compact"));
const Loader_1 = require("../abstract/Loader");
const Shard_1 = require("../abstract/Shard");
const Timeline_1 = require("../abstract/Timeline");
const misc_1 = require("../internal/misc");
const VCCaches_1 = require("./VCCaches");
const VCFlavor_1 = require("./VCFlavor");
const VCTrace_1 = require("./VCTrace");
/**
* Guest VC: has minimum permissions. Typically if the user is not logged in,
* this VC is used.
*/
exports.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.
*/
exports.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.
*/
class VC {
/**
* 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, } = {}) {
return new VC(new VCTrace_1.VCTrace(trace), exports.GUEST_ID, null, new Map(), new Map(), { heartbeat: async () => { }, delay: delay_1.default }, true, cachesExpirationMs ?? 0);
}
/**
* This is to show VCs in console.log() and inspect() nicely.
*/
[util_1.inspect.custom]() {
return `<${this.toString()}>`;
}
// The actual implementation of the above overloads.
cache(ClassOrTag, creator) {
this.caches ??= new VCCaches_1.VCCaches(this.cachesExpirationMs);
let cache = this.caches.get(ClassOrTag);
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(HandlerClass) {
let symbol = HandlerClass.$loader;
if (!symbol) {
symbol = HandlerClass.$loader = Symbol(HandlerClass.name);
}
return this.cache(symbol, () => new Loader_1.Loader(() => new HandlerClass(this)));
}
/**
* Returns Shard+schemaName timeline which tracks replica staleness for the
* particular schema name (most likely, table).
*/
timeline(shard, schemaName) {
const key = shard.no + ":" + schemaName;
let timeline = this.timelines.get(key);
if (timeline === undefined) {
timeline = new Timeline_1.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() {
const timelines = {};
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) {
let deserialized = false;
for (const dataStr of dataStrs) {
if (dataStr) {
const data = JSON.parse(dataStr);
for (const [key, timelineStr] of Object.entries(data)) {
const oldTimeline = this.timelines.get(key) ?? null;
this.timelines.set(key, Timeline_1.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() {
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() {
if (this.freshness === Shard_1.MASTER) {
return this;
}
return new VC(this.trace, this.principal, Shard_1.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() {
if (this.freshness === Shard_1.STALE_REPLICA) {
return this;
}
return new VC(this.trace, this.principal, Shard_1.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() {
if (this.freshness === null) {
return this;
}
return new VC(this.trace, this.principal, null, this.timelines, this.flavors, this.heartbeater, this.isRoot, this.cachesExpirationMs);
}
withFlavor(...args) {
const prepend = args[0] === "prepend" ? args.shift() : undefined;
const pairs = args
.filter((flavor) => flavor !== undefined)
.map((flavor) => [flavor.constructor, flavor]);
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) {
return new VC(new VCTrace_1.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) {
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() {
return new VC(this.trace, exports.OMNI_ID, this.freshness, this.timelines, this.flavors, this.heartbeater, this.isRoot, this.cachesExpirationMs);
}
/**
* Creates a new VC downgraded to guest permissions.
*/
toGuest() {
return new VC(this.trace, exports.GUEST_ID, this.freshness, this.timelines, this.flavors, this.heartbeater, this.isRoot, this.cachesExpirationMs);
}
/**
* Checks if it's an omni VC.
*/
isOmni() {
return this.principal === exports.OMNI_ID;
}
/**
* Checks if it's a guest VC.
*/
isGuest() {
return this.principal === exports.GUEST_ID;
}
/**
* Checks if it's a regular user (i.e. owning) VC.
*/
isLoggedIn() {
return !this.isOmni() && !this.isGuest();
}
/**
* Returns VC's flavor of the particular type.
*/
flavor(flavor) {
return this.flavors.get(flavor) ?? null;
}
/**
* Used for debugging purposes.
*/
toString(withInstanceNumber = false) {
const flavorsStr = (0, compact_1.default)([
withInstanceNumber && this.instanceNumber,
...[...this.flavors.values()].map((flavor) => flavor.toDebugString()),
]).join(",");
return (`vc:${this.principal}` +
(flavorsStr ? `(${flavorsStr})` : "") +
(this.freshness === Shard_1.MASTER
? ":master"
: this.freshness === Shard_1.STALE_REPLICA
? ":stale_replica"
: ""));
}
/**
* Returns a debug annotation of this VC.
*/
toAnnotation() {
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(VCFlavor_1.VCWithStacks)) {
// This is expensive, only enabled explicitly for this flavor.
return {
...this.annotationCache,
debugStack: (0, misc_1.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
*/
toLowerInternal(principal) {
const newPrincipal = principal ? principal.toString() : exports.GUEST_ID;
if (this.principal === newPrincipal && this.freshness !== Shard_1.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 !== exports.GUEST_ID && newPrincipal !== exports.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_1.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 === Shard_1.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.
*/
constructor(
/** Trace information to quickly find all the requests done by this VC in
* debug logs. Trace is inherited once VC is derived. */
trace,
/** A principal (typically user ID) represented by this VC. */
principal,
/** 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. */
freshness,
/** Replication WAL position per Shard & Ent. Used to make decisions,
* should a request be sent to a replica or to the master. */
timelines,
/** Sticky objects attached to the VC (and inherited when deriving). */
flavors,
/** 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. */
heartbeater,
/** If true, it's the initial "root" VC which is not yet derived to any
* user's VC. */
isRoot,
/** 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. */
cachesExpirationMs) {
this.trace = trace;
this.principal = principal;
this.freshness = freshness;
this.timelines = timelines;
this.flavors = flavors;
this.heartbeater = heartbeater;
this.isRoot = isRoot;
this.cachesExpirationMs = cachesExpirationMs;
this.caches = undefined;
this.instanceNumber = (instanceNumber++).toString(); // string makes it visible in Chrome memory dump profiler
}
}
exports.VC = VC;
__decorate([
(0, fast_typescript_memoize_1.Memoize)()
], VC.prototype, "toOmniDangerous", null);
__decorate([
(0, fast_typescript_memoize_1.Memoize)()
], VC.prototype, "toGuest", null);
__decorate([
(0, fast_typescript_memoize_1.Memoize)()
], VC.prototype, "toLowerInternal", null);
//# sourceMappingURL=VC.js.map