Skip to content

Commit

Permalink
feat(Web): Expose helper for generating node identifiers
Browse files Browse the repository at this point in the history
  • Loading branch information
alex-ketch committed Feb 24, 2022
1 parent 5717aa2 commit 6752a01
Show file tree
Hide file tree
Showing 6 changed files with 187 additions and 3 deletions.
37 changes: 35 additions & 2 deletions web/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion web/package.json
Expand Up @@ -56,6 +56,7 @@
"@stencila/eslint-config": "3.0.0",
"@stencila/schema": "file:../schema",
"@types/jest": "27.0.1",
"@types/lolex": "5.1.2",
"@types/prosemirror-collab": "1.1.2",
"@types/prosemirror-commands": "1.0.4",
"@types/prosemirror-dropcursor": "1.0.3",
Expand All @@ -70,7 +71,8 @@
"@types/prosemirror-transform": "1.1.4",
"@types/prosemirror-view": "1.19.1",
"jest": "27.1.0",
"microbundle": "^0.14.2",
"lolex": "6.0.0",
"microbundle": "0.14.2",
"parcel": "^2.0.0-nightly.991",
"ts-jest": "27.0.5",
"typescript": "4.4.2"
Expand Down
1 change: 1 addition & 0 deletions web/src/index.ts
Expand Up @@ -11,6 +11,7 @@ export * as client from './client'
export type { Client } from './client'
export * as documents from './documents'
export * as patches from './patches'
export * as utils from './utils'

export const main = (
clientId: ClientId,
Expand Down
1 change: 1 addition & 0 deletions web/src/utils/index.ts
@@ -0,0 +1 @@
export * from './uid'
61 changes: 61 additions & 0 deletions web/src/utils/uid.test.ts
@@ -0,0 +1,61 @@
import lolex from 'lolex'
import { epoch, generate, parse } from './uid'

const clock = lolex.install()

beforeEach(() => {
clock.setSystemTime(epoch)
})

afterEach(() => {
clock.reset()
})

describe('generate', () => {
test('structure', () => {
expect(generate('ab').family).toBe('ab')
expect(generate('abc').value.length).toBe(34)
expect(generate('a').value).toMatch(
/^aa\.([0-9a-f]{10})\.([0-9A-Za-z]{20})$/
)
})

test('ordered by time', () => {
const gen = (): string => generate('ab').value

const id0 = gen()
expect(id0).toMatch(/^ab\.0000000000\.([0-9A-Za-z]{20})$/)

clock.tick(1)
const id1 = gen()
expect(id1).toMatch(/^ab\.0000000001\.([0-9A-Za-z]{20})$/)
expect(id1 > id0).toBe(true)

clock.tick(9)
const id2 = gen()
expect(id2).toMatch(/^ab\.000000000a\.([0-9A-Za-z]{20})$/)
expect(id2 > id1).toBe(true)

clock.tick(16)
const id3 = gen()
expect(id3).toMatch(/^ab\.000000001a\.([0-9A-Za-z]{20})$/)
expect(id3 > id1).toBe(true)
})
})

describe('parse', () => {
test('ok', () => {
expect(parse('aa.00000000.11111111111111111111')).toEqual({
family: 'aa',
time: new Date(epoch),
rand: '11111111111111111111',
})
})

test('bad', () => {
expect(parse('')).toBeUndefined()
expect(parse('a')).toBeUndefined()
expect(parse('aa')).toBeUndefined()
expect(parse('aa01')).toBeUndefined()
})
})
86 changes: 86 additions & 0 deletions web/src/utils/uid.ts
@@ -0,0 +1,86 @@
import { customAlphabet } from 'nanoid'

/**
* The epoch used for calculating
* the time stamp. Chosen as a recent time
* that was easily remembered.
* Happens to correspond to 2017-07-14T02:40:00.000Z.
*/
export const epoch = 1500000000000

/**
* A random id generator using custom alphabet and length.
*/
const nanoid = customAlphabet(
'0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz',
20
)

/**
* Generate a unique identifier.
*
* Generated identifiers:
*
* - are URL safe
* - contain information on the type of object that they
* are identifying
* - are roughly sortable by time of generation
* - have an extremely low probability of collision
*
* Generated identifiers have a fixed length of 32 characters made up
* of three parts separated by dots:
*
* - 2 characters in the range `[a-z]` that identifying the "family" of
* identifiers, usually the type of object
* the identifier is for e.g. `rq` = request
*
* - 10 characters in the range `[0-9a-f]` that are the hexadecimal encoding of the
* seconds since `2017-07-14T02:40:00.000Z`the hexadecimal
*
* - 20 characters in the range `[0-9A-Za-z]` that are randomly generated
*
* @see {@link https://segment.com/blog/a-brief-history-of-the-uuid/|A brief history of the UUID}
* @see {@link https://zelark.github.io/nano-id-cc/|Nano ID Collision Calculator}
*/
export function generate(code: string): { family: string; value: string } {
const family =
code.length === 2
? code
: code.length === 0
? 'uu'
: code.length === 1
? code.repeat(2)
: code.slice(0, 2)
const time = (Date.now() - epoch).toString(16).padStart(10, '0')
const rand = nanoid()
const value = `${family}.${time}.${rand}`
return { family, value }
}

/**
* Parse a unique identifier.
*
* Extracts the parts from the identifier: `family`, `time` and `rand`.
*/
export function parse(id: string):
| {
family: string
time: Date
rand: string
}
| undefined {
const [_, family, t, rand] =
/^([a-z]{2})\.([0-9a-f]{8})\.([0-9A-Za-z]{20})$/.exec(id) ?? []

if (!family || !t || !rand) return

let seconds
try {
seconds = parseInt(t ?? '', 16)
} catch (error) {
return undefined
}

const time = new Date(seconds + epoch)
return { family, time, rand }
}

0 comments on commit 6752a01

Please sign in to comment.