const KIND_TAG_BITS = 2 const CHUNK_BITS = 4 const SCHEMAS = { '0-2': { name: 'item', fields: ['owner_identity_id', 'item_local_id'], interpret: ([owner_identity_id, item_local_id]) => ({kind: 'item', owner_identity_id, item_local_id}) }, '1-2': { name: 'storage_location', fields: ['owner_identity_id', 'storage_location_id'], interpret: ([owner_identity_id, storage_location_id]) => ({kind: 'storage_location', owner_identity_id, storage_location_id}) }, '2-1': { name: 'category', fields: ['category_id'], interpret: ([category_id]) => ({kind: 'category', category_id}) }, '3-2': { name: 'workflow', fields: ['owner_identity_id', 'workflow_id'], interpret: ([owner_identity_id, workflow_id]) => ({kind: 'workflow', owner_identity_id, workflow_id}) }, '4-1': { name: 'group', fields: ['group_id'], interpret: ([group_id]) => ({kind: 'group', group_id}) }, '5-1': { name: 'file', fields: ['file_id'], interpret: ([file_id]) => ({kind: 'file', file_id}) }, } const BASE64URL_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_' const DIRECT_KIND_COUNT = 2 ** KIND_TAG_BITS - 1 class BitWriter { constructor() { this.bits = [] } writeBits(value, count) { value = BigInt(value) for (let i = count - 1; i >= 0; i--) { this.bits.push(Number((value >> BigInt(i)) & 1n)) } } toBase64Url() { const padded = this.bits.slice() while (padded.length % 6 !== 0) { padded.push(0) } let token = '' for (let i = 0; i < padded.length; i += 6) { let n = 0 for (let j = 0; j < 6; j++) { n = (n << 1) | padded[i + j] } token += BASE64URL_ALPHABET[n] } return token } } class BitReader { constructor(bits) { this.bits = bits this.pos = 0 } readBits(count) { let value = 0n for (let i = 0; i < count; i++) { value = (value << 1n) | BigInt(this.bits[this.pos] ?? 0) this.pos++ } return value } } function base64UrlToBits(text) { const bits = [] for (const char of text) { const n = BASE64URL_ALPHABET.indexOf(char) if (n === -1) { throw new Error(`invalid short id: '${char}' is not a URL-safe base64 character`) } for (let i = 5; i >= 0; i--) { bits.push((n >> i) & 1) } } return bits } function writeChunkedInt(writer, value, chunkBits) { value = BigInt(value) if (value < 0n) { throw new Error('short id values must be non-negative integers') } const mask = (1n << BigInt(chunkBits)) - 1n const chunks = [] let remaining = value do { chunks.unshift(remaining & mask) remaining >>= BigInt(chunkBits) } while (remaining > 0n) chunks.forEach((chunk, i) => { writer.writeBits(i === chunks.length - 1 ? 0 : 1, 1) writer.writeBits(chunk, chunkBits) }) } function readChunkedInt(reader, chunkBits) { let value = 0n for (; ;) { const more = reader.readBits(1) const data = reader.readBits(chunkBits) value = (value << BigInt(chunkBits)) | data if (more === 0n) { return value } } } function entriesForKindIndex(kindIndex) { return Object.entries(SCHEMAS).filter(([key]) => key.startsWith(`${kindIndex}-`)) } function schemaForKindIndex(kindIndex) { const matches = entriesForKindIndex(kindIndex) if (matches.length === 0) { throw new Error(`unknown short id kind index: ${kindIndex}`) } if (matches.length > 1) { throw new Error( `ambiguous short id kind index ${kindIndex}: base-level decoding needs exactly one ` + `field count per kind, found arities ${matches.map(([, s]) => s.fields.length).join(', ')} ` + `- only deserializeShortId's (kind, fields.length) lookup can tell those apart` ) } return matches[0][1] } function schemaForKindAndArity(kindIndex, arity) { const schema = SCHEMAS[`${kindIndex}-${arity}`] if (!schema) { throw new Error(`unknown short id schema for kind ${kindIndex} with ${arity} field value(s)`) } return schema } export function encodeShortId(ints) { if (!Array.isArray(ints) || ints.length === 0) { throw new Error('short id must be a non-empty list of [kindIndex, ...fieldValues]') } const [kindIndex, ...fieldValues] = ints const schema = schemaForKindIndex(kindIndex) if (fieldValues.length !== schema.fields.length) { throw new Error( `short id kind index ${kindIndex} ('${schema.name}') needs ${schema.fields.length} field value(s), got ${fieldValues.length}` ) } const writer = new BitWriter() if (kindIndex < DIRECT_KIND_COUNT) { writer.writeBits(kindIndex, KIND_TAG_BITS) } else { writer.writeBits(DIRECT_KIND_COUNT, KIND_TAG_BITS) writeChunkedInt(writer, kindIndex - DIRECT_KIND_COUNT, CHUNK_BITS) } for (const value of fieldValues) { writeChunkedInt(writer, value, CHUNK_BITS) } return '~' + writer.toBase64Url() } export function decodeShortId(token) { if (typeof token !== 'string' || !token.startsWith('~')) { throw new Error("short id must start with '~'") } const reader = new BitReader(base64UrlToBits(token.slice(1))) let kindIndex = Number(reader.readBits(KIND_TAG_BITS)) if (kindIndex === DIRECT_KIND_COUNT) { kindIndex = DIRECT_KIND_COUNT + Number(readChunkedInt(reader, CHUNK_BITS)) } const schema = schemaForKindIndex(kindIndex) const fieldValues = schema.fields.map(() => Number(readChunkedInt(reader, CHUNK_BITS))) return [kindIndex, ...fieldValues] } export function deserializeShortId(ints) { const [kindIndex, ...fieldValues] = ints const schema = schemaForKindAndArity(kindIndex, fieldValues.length) return schema.interpret(fieldValues) } export function serializeShortId({kind, ...fieldValues}) { const entry = Object.entries(SCHEMAS).find(([, s]) => s.name === kind) if (!entry) { throw new Error(`unknown short id kind: ${kind}`) } const [key, schema] = entry const kindIndex = Number(key.split('-')[0]) const values = schema.fields.map(name => { const value = fieldValues[name] if (value === undefined) { throw new Error(`missing field '${name}' for short id kind '${kind}'`) } return value }) return [kindIndex, ...values] }