@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
text/typescript
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;