UNPKG

@localsecurity/name-id

Version:

A utility to create and parse lexicographically sortable ID's with embeded model names and created timestamp

562 lines (526 loc) 18.5 kB
import { Model } from "@localsecurity/types"; /** * Models // todo -> add Models / ModelNames enum to types package * */ enum Models { ActivityLog = "ActivityLog", Address = "Address", AppliedCameraPlan = "AppliedCameraPlan", AppliedLivePatrol = "AppliedLivePatrol", AppliedOperatorMinutes = "AppliedOperatorMinutes", AppliedProduct = "AppliedProduct", AppliedSetupFee = "AppliedSetupFee", AudioCustomMessageType = "AudioCustomMessageType", AuditLog = "AuditLog", CallLog = "CallLog", CallNotice = "CallNotice", Camera = "Camera", CameraAccessUrls = "CameraAccessUrls", CameraCredentials = "CameraCredentials", CameraEvent = "CameraEvent", CameraImmixConfig = "CameraImmixConfig", CameraPlan = "CameraPlan", CameraQualitySettings = "CameraQualitySettings", CameraSceneDetectionResult = "CameraSceneDetectionResult", CameraSceneDetectionResultItem = "CameraSceneDetectionResultItem", CameraSceneDetectionSettings = "CameraSceneDetectionSettings", CameraSceneDetectionThreshold = "CameraSceneDetectionThreshold", Contact = "Contact", Customer = "Customer", CustomerUserPermission = "CustomerUserPermission", CustomerUserPermissionSet = "CustomerUserPermissionSet", DashboardDetail = "DashboardDetail", EmailLog = "EmailLog", ExchangeRateSetting = "ExchangeRateSetting", Incident = "Incident", IncidentActionDetail = "IncidentActionDetail", IncidentActionList = "IncidentActionList", IncidentBundleTime = "IncidentBundleTime", IncidentCodeDetail = "IncidentCodeDetail", IncidentDataToShare = "IncidentDataToShare", IncidentNote = "IncidentNote", IncidentShareActionItems = "IncidentShareActionItems", IncidentShareContactInformation = "IncidentShareContactInformation", IncidentShareEntry = "IncidentShareEntry", IncidentShareEntryAccess = "IncidentShareEntryAccess", IncidentShareEntyNote = "IncidentShareEntyNote", IncidentShareEvent = "IncidentShareEvent", IncidentShareIntegrator = "IncidentShareIntegrator", IncidentShareSettings = "IncidentShareSettings", IncidentShareSite = "IncidentShareSite", Invoice = "Invoice", InvoicePayment = "InvoicePayment", InvoiceTransferPayment = "InvoiceTransferPayment", LivePatrol = "LivePatrol", Master = "Master", Notification = "Notification", NotificationSetting = "NotificationSetting", ObjectGroup = "ObjectGroup", OperatorMinutes = "OperatorMinutes", PaymentTransferSettings = "PaymentTransferSettings", Phone = "Phone", ProcedureTask = "ProcedureTask", Product = "Product", RapidSOSActivity = "RapidSOSActivity", RapidSOSAttachment = "RapidSOSAttachment", RapidSOSCivicAddress = "RapidSOSCivicAddress", RapidSOSDetails = "RapidSOSDetails", RapidSOSEmergencyContact = "RapidSOSEmergencyContact", RapidSOSEventDetails = "RapidSOSEventDetails", RapidSOSIncident = "RapidSOSIncident", RapidSOSLocation = "RapidSOSLocation", RapidSOSLogMessage = "RapidSOSLogMessage", RspndrActivity = "RspndrActivity", RspndrEvents = "RspndrEvents", RspndrIncident = "RspndrIncident", RspndrIncidentCheckItem = "RspndrIncidentCheckItem", RspndrIncidentGroundingRule = "RspndrIncidentGroundingRule", SMSLog = "SMSLog", SOP = "SOP", SecondaryCameraEvent = "SecondaryCameraEvent", SetupFee = "SetupFee", SignalTestLog = "SignalTestLog", Site = "Site", SiteEmergencyContact = "SiteEmergencyContact", SiteIntegration = "SiteIntegration", SiteSubscription = "SiteSubscription", SiteSupvisdSetting = "SiteSupvisdSetting", SiteVMSDetails = "SiteVMSDetails", StripePaymentMethod = "StripePaymentMethod", StripePayoutDetails = "StripePayoutDetails", SubscriptionStripeDetail = "SubscriptionStripeDetail", SupvisdEventRule = "SupvisdEventRule", Tax = "Tax", TaxType = "TaxType", TaxesTaxType = "TaxesTaxType", TrackTikActivity = "TrackTikActivity", TrackTikDetails = "TrackTikDetails", TrackTikIncident = "TrackTikIncident", TrackTikTask = "TrackTikTask", TransactionLineItem = "TransactionLineItem", TransactionLineItemTax = "TransactionLineItemTax", Trigger = "Trigger", User = "User", UserVMSDetails = "UserVMSDetails", VMSDetails = "VMSDetails", WalletTransfer = "WalletTransfer", WebhookHealth = "WebhookHealth", } type ModelName = Model[keyof Model]["__typename"]; /** * A sortable ID in the format "{model}_{reversedTimestamp}{random}". */ type NameIdString = `${Lowercase<Models>}_${string}` & { __brand: "NameIdString"; }; /** * A uuid v4 (ish) */ type UUIDString = `${string}-${string}-${string}-${string}-${string}`; /** * A sortable ID in the format "{model}_{reversedTimestamp}{random}". */ const nameIdStringRegex = new RegExp( `^(${Object.values(Models) .map((n) => n.toLowerCase()) .join("|")})_[2-7a-z]{22,}$`, ); /** * A valid uuid v4 */ const uuidStringRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; /** * Checks if the value is a valid uuid v4 * @param value A uuid string hopefully * @returns true or false */ function isUUID(value: unknown): value is UUIDString { const regexMatch = typeof value === "string" && uuidStringRegex.test(value); return regexMatch; } /** * Checks if the value is a String * @param value A string hopefully * @returns true or false */ function isString(value: unknown): boolean { try { return typeof value == "string"; } catch (e) { return false; } } /** * Checks if the value is a NameID (a sortable ID in the format "{model}_{reversedTimestamp}{random}") * @param value A valid NameID string hopefully * @returns true or false */ function isNameId(value: unknown): value is NameIdString { const parts = typeof value == "string" ? value.split("_") : []; if (parts.length !== 2) return false; const timestamp = parts[1].slice(0, 6); if (timestamp.length !== 6) return false; const regexMatch = typeof value === "string" && nameIdStringRegex.test(value); if (regexMatch == false) return false; return true; } /** * Checks if the value, or value['id'], is a valid uuid v4 * @param value A string or object with an 'id' property * @returns true or false * @example * isHasUUID("xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx") // true * isHasUUID({"id":"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"}) // true */ function isHasUUID(value: unknown): boolean { try { if (isString(value)) return isUUID(value); if (isString((value as any)["id"])) return isUUID((value as any)["id"]); return false; } catch (e) { return false; } } /** * Checks if the value is a known name for a Model * @param value * @returns true or false */ function isModelName(value: unknown | ModelName): value is ModelName { return ( typeof value === "string" && Object.values(Models).includes(value as any) ); } /** * Extracts the Model Name from a NameID * @param nameId A string in the format "{model}_{reversedTimestamp}{random}" * @returns A ModelName * @example * extractModelFromNameId('user_012345234567abcdefghijklmnopqrstuv') // returns 'User' */ function extractModelFromNameId(nameId: NameIdString): ModelName { const typenames = Object.values(Models); const nameIdPrefix = nameId.split("_")[0]; for (let name of typenames) { if (name.toLowerCase() === nameIdPrefix) { return name as ModelName; } } throw Error("get __typename failed"); } /** * Extracts the Model Name from a NameID with a novel (unknown) model prefix * @param nameId A string in the format "{model}_{reversedTimestamp}{random}" * @returns A ModelName * @example * extractModelFromNameId('cat_012345234567abcdefghijklmnopqrstuv') // returns 'Cat' */ function extractModelFromNameIdSkipValidation(nameId: NameIdString): string { let parts = nameId.split("_")[0].split(""); const firstLetter = parts.shift()?.toUpperCase(); return firstLetter + parts.join(""); } /** * Extracts the createdAt timestamp from a NameID * @param nameId A string in the format "{model}_{reversedTimestamp}{random}" * @param offset Optionally you can include an offset if a custom offset was used * @returns a Date */ function extractTimestampFromNameId( nameId: NameIdString, offset: number = 1500000000000, ): Date { const timestamp = new Date( (offset / 1000 + parseInt( nameId.split("_")[1].slice(0, 6).split("").reverse().join(""), 32, )) * 1000, ); return timestamp; } /** * Create a new Model Object with a valid NameID from an existing Model Object or a Model Name */ abstract class MutableModel { id!: UUIDString | NameIdString; nameId!: NameIdString; createdAt!: string; updatedAt!: string; get __typename(): ModelName { const typenames = Object.values(Models); const nameIdPrefix = this.nameId.split("_")[0]; for (let name of typenames) { if (name.toLowerCase() === nameIdPrefix) { return name as ModelName; } } throw Error("get __typename failed"); } /** * @param init An existing Model Object, or a Model Name */ constructor(init: Partial<Model[keyof Model]> | ModelName) { if (init instanceof Object) { const maybeTypename = (init as any).__typename; const hasTypename = typeof maybeTypename === "string" && isModelName(maybeTypename) ? maybeTypename : false; const maybeNameId = (init as any).nameId; const hasNameId = typeof maybeNameId === "string" && isNameId(maybeNameId) ? maybeNameId : false; if (hasNameId) { Object.assign(this, init); } else if (hasTypename) { this.nameId = new NameID(hasTypename).value; Object.assign(this, init); } else { throw Error("no valid __typename or name id in object"); } } else { this.nameId = new NameID(init).value; } if (this.createdAt == undefined) this.createdAt = new Date().toISOString(); if (this.updatedAt == undefined) this.updatedAt = new Date().toISOString(); if (this.id == undefined) this.id = this.nameId; } } /** * Represents a sortable ID. * * This class encapsulates the generation, validation, and manipulation of sortable IDs, * which are used as primary keys in our database, particularly for time-series data. */ class NameID { private _lexicographicBase32 = "234567abcdefghijklmnopqrstuvwxyz"; private _randomLength = 16; private _offset = 1500000000000; // 2022-11-11T01:53:20 UTC private _value: NameIdString; /** * @param { ModelName | Model | NameId } input - Can be a ModelName, a Model object (with __typename and createdAt), or a valid nameId string. * @param { string | number | Date } time - Defaults to now. * @param {boolean} skip - Skip 'Is Valid Model Name' check. * @throws { Error } - Throws an error if the input is invalid. * * @example * const NameID_For_Model = new NameID("User"); * const NameID_From_Model = new NameID({ __typename: "User", ...}); * const NameID_From_Model_With_UUID = new NameID({id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", __typename: "User", ... }); */ constructor( input: Model[ModelName] | ModelName | NameIdString, time: string | number | Date = Date.now(), skip: boolean = false, ) { if (isModelName(input)) { this._value = this.generateNameID(input, time); } else if (isNameId(input)) { let name = extractModelFromNameId(input); time = extractTimestampFromNameId(input, this._offset); this._value = this.generateNameID(name, time); } else if ( typeof input["__typename"] == "string" && isModelName(input["__typename"]) && isHasUUID(input) ) { this._value = this.fromUUID(input); } else if ( typeof input["__typename"] == "string" && isModelName(input["__typename"]) ) { let name = input["__typename"]; time = (input as any)["createdAt"] ?? time; this._value = this.generateNameID(name, time); } else if ( typeof (input as any)["nameId"] == "string" && isNameId((input as any)["nameId"]) ) { let name = extractModelFromNameId((input as any)["nameId"]); time = extractTimestampFromNameId((input as any)["nameId"], this._offset); this._value = this.generateNameID(name, time); } else if (skip) { if (typeof input !== "string") throw Error("You must pass a string with skip"); else this._value = this.generateNameID(input as ModelName, time); } else { throw Error(`invalid new NameID(${JSON.stringify({ input })}`); } } /** * Returns The string representation of the NameID. * @example * const nameId = new NameID("User"); * console.log(nameId.toString()); // Output: user_012345234567abcdefghijklmnopqrstuv */ get value(): NameIdString { return this._value as NameIdString; } /** * Convert a legacy object with a uuid id to a NameIdString * @param object Any valid Model object * @returns */ private fromUUID(object: any): any { let id: string = ""; const model = object["__typename"]; const hasFX = typeof object["country"] == "string" ? object["country"] : false; const hasId = typeof object["id"] == "string" ? object["id"].replace(/-/g, "") : false; const hasUsername = typeof object["username"] == "string" ? object["username"] : false; if (hasId) id = hasId; else if (hasFX) id = hasFX; else if (hasUsername) id = hasUsername; else throw Error(`no id or username or country: ${JSON.stringify(object)}`); return `${model}_${this.generateEncodedTimestamp(object["createdAt"])}${id}`; } /** * Generates a sortable ID with model prefix and reversed timestamp. * * This function creates a unique identifier suitable for use as a primary key * in a database, particularly for time-series data or event-driven systems. * It combines a model identifier, a reversed timestamp for reverse chronological * sorting, and a random component for uniqueness. * * @param name - The model type name (e.g., "User"). * @param time - The timestamp to encode. If not provided, the current time is used. * * @returns The generated sortable ID in the format "{model}_{reversedTimestamp}{random}". * * @example * const userId = generateNameID("User"); * const cameraEventId = generateNameID("CameraEvent", new Date("2024-08-15T12:30:00Z")); */ private generateNameID( name: ModelName | Lowercase<ModelName>, time: string | number | Date = new Date(), ): NameIdString { const encodedTimestamp = this.generateEncodedTimestamp(time); let randomPart = ""; for (let i = 0; i < this._randomLength; i++) { randomPart += this._lexicographicBase32[ Math.floor(Math.random() * this._lexicographicBase32.length) ]; } return `${name.toLowerCase()}_${encodedTimestamp}${randomPart}` as NameIdString; } private generateEncodedTimestamp( time: string | number | Date = new Date(), ): string { time = new Date(time); const timestampMillis = time.getTime(); const timestampSeconds = Math.floor( (timestampMillis - this._offset) / 1000, ); const timestampBase32 = timestampSeconds.toString(32).padStart(6, "0"); const encodedTimestamp = timestampBase32.split("").reverse().join(""); return encodedTimestamp; } /** * Checks if a given ID is valid based on the expected format. * * This function validates if a string conforms to the expected sortable ID * format: "model_reversedTimestamp_random". * @returns {boolean} - True if the ID is valid, false otherwise. * * @example * const validId = generateNameID("user"); * const invalidId = "user-12345-abcde"; * * console.log(isValidId(validId)); // true * console.log(isValidId(invalidId)); // false */ get isValid(): boolean { return isNameId(this._value); } /** * Decodes the timestamp from a NameID. * * @returns {Date | null} - The decoded timestamp as a Date object, or null if the ID is invalid. * * @example * const { timestamp } = new NameID("event_abcdefghi"); * console.log(timestamp.toISOString()); */ get timestamp(): Date | null { try { if (!this.isValid) throw Error("invalid nameId"); return extractTimestampFromNameId(this._value, this._offset); } catch (e) { return null; } } /** * Generates a sortable ID with model prefix and reversed timestamp. * * This function creates a unique identifier suitable for use as a primary key * in a database, particularly for time-series data or event-driven systems. * It combines a model identifier, a reversed timestamp for reverse chronological * sorting, and a random component for uniqueness. * * @param value - Can be a ModelName, a Model object (with __typename and createdAt), or a valid nameId string. * @param time - The timestamp to encode. If not provided, the current time is used. * * @returns The generated sortable ID in the format "model_{reversedTimestamp}{random}". * * @example * const userId = generateNameID("User"); * const cameraEventId = generateNameID("CameraEvent", new Date("2024-08-15T12:30:00Z")); */ static from(value: unknown, time?: string | number | Date): NameID { return new NameID(value as any); } /** * Checks if the input is a value nameId * @param value A nameId. * @param skip Skip 'Is Valid Model Name' check. * @returns A model name and timestamp */ static parse( value: string, skip: boolean = false, ): { name: ModelName | string; timestamp: Date } { if (skip) { const name = extractModelFromNameIdSkipValidation(value as NameIdString); const timestamp = extractTimestampFromNameId(value as NameIdString); return { name, timestamp }; } if (!isNameId(value)) throw Error("Invalid NameIdString"); const name = extractModelFromNameId(value); const timestamp = extractTimestampFromNameId(value); return { name, timestamp }; } } /** * Classes */ export { NameID, MutableModel }; /** * Regexp */ export { uuidStringRegex, nameIdStringRegex }; /** * Utilities */ export { isUUID, isString, isNameId, isHasUUID, isModelName, extractModelFromNameId, extractTimestampFromNameId, extractModelFromNameIdSkipValidation, }; /** * Types */ export type { Models, ModelName, UUIDString, NameIdString }; export default NameID;