UNPKG

@clickup/ent-framework

Version:

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

384 lines 16.8 kB
"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