stash
This commit is contained in:
parent
7d9f67a77a
commit
3299c97392
13 changed files with 1407 additions and 327 deletions
217
frontend/src/short-id.js
Normal file
217
frontend/src/short-id.js
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
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]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue