From 6752a0185eaf96da56b05ef60daeac337ed221e3 Mon Sep 17 00:00:00 2001 From: alex-ketch Date: Thu, 24 Feb 2022 13:23:23 -0500 Subject: [PATCH] feat(Web): Expose helper for generating node identifiers --- web/package-lock.json | 37 ++++++++++++++++- web/package.json | 4 +- web/src/index.ts | 1 + web/src/utils/index.ts | 1 + web/src/utils/uid.test.ts | 61 +++++++++++++++++++++++++++ web/src/utils/uid.ts | 86 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 187 insertions(+), 3 deletions(-) create mode 100644 web/src/utils/index.ts create mode 100644 web/src/utils/uid.test.ts create mode 100644 web/src/utils/uid.ts diff --git a/web/package-lock.json b/web/package-lock.json index 30a1b02230..8c93906a81 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -39,6 +39,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", @@ -53,7 +54,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" @@ -85,7 +87,7 @@ }, "../schema": { "name": "@stencila/schema", - "version": "1.17.0", + "version": "1.18.0", "license": "Apache-2.0", "devDependencies": { "@semantic-release/exec": "6.0.3", @@ -4922,6 +4924,12 @@ "@types/node": "*" } }, + "node_modules/@types/lolex": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@types/lolex/-/lolex-5.1.2.tgz", + "integrity": "sha512-ZmfpBbjp2obo1dKm2l57tL/yVAVfDryHX/6nsivGQ1Gd86Ylu6GFNdxH69kUmYWmmO+Yedq9i7m0/obFww4XiQ==", + "dev": true + }, "node_modules/@types/minimatch": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz", @@ -12836,6 +12844,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lolex": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lolex/-/lolex-6.0.0.tgz", + "integrity": "sha512-ad9IBHbfVJ3bPAotDxnCgJgKcNK5/mrRAfbJzXhY5+PEmuBWP7wyHQlA6L8TfSfPlqlDjY4K7IG6mbzsrIBx1A==", + "deprecated": "lolex has been renamed to @sinonjs/fake-timers. No API changes made. Please use the new package instead", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^1.7.0" + } + }, "node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -21679,6 +21697,12 @@ "@types/node": "*" } }, + "@types/lolex": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@types/lolex/-/lolex-5.1.2.tgz", + "integrity": "sha512-ZmfpBbjp2obo1dKm2l57tL/yVAVfDryHX/6nsivGQ1Gd86Ylu6GFNdxH69kUmYWmmO+Yedq9i7m0/obFww4XiQ==", + "dev": true + }, "@types/minimatch": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz", @@ -27948,6 +27972,15 @@ "is-unicode-supported": "^0.1.0" } }, + "lolex": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lolex/-/lolex-6.0.0.tgz", + "integrity": "sha512-ad9IBHbfVJ3bPAotDxnCgJgKcNK5/mrRAfbJzXhY5+PEmuBWP7wyHQlA6L8TfSfPlqlDjY4K7IG6mbzsrIBx1A==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1.7.0" + } + }, "lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", diff --git a/web/package.json b/web/package.json index 04549b1c61..45d8ca22fe 100644 --- a/web/package.json +++ b/web/package.json @@ -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", @@ -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" diff --git a/web/src/index.ts b/web/src/index.ts index d781f968c6..865f5fdd22 100644 --- a/web/src/index.ts +++ b/web/src/index.ts @@ -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, diff --git a/web/src/utils/index.ts b/web/src/utils/index.ts new file mode 100644 index 0000000000..9e008685cf --- /dev/null +++ b/web/src/utils/index.ts @@ -0,0 +1 @@ +export * from './uid' diff --git a/web/src/utils/uid.test.ts b/web/src/utils/uid.test.ts new file mode 100644 index 0000000000..95eeca343c --- /dev/null +++ b/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() + }) +}) diff --git a/web/src/utils/uid.ts b/web/src/utils/uid.ts new file mode 100644 index 0000000000..5e5f866c96 --- /dev/null +++ b/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 } +}