diff --git a/.changeset/generate-firestore-id.md b/.changeset/generate-firestore-id.md new file mode 100644 index 0000000..e9c2ed2 --- /dev/null +++ b/.changeset/generate-firestore-id.md @@ -0,0 +1,5 @@ +--- +'fireworkers': minor +--- + +Add `generateDocumentId()`, a utility that returns a random 20-character `[A-Za-z0-9]` ID in the same format Firestore uses for auto-generated document IDs. Useful when you need to know a document's ID before writing it (e.g. to reference it from sibling writes in a `batch`). Ported from `@firebase/firestore`'s `AutoId.newId()` — uses `crypto.getRandomValues` with rejection sampling to avoid modulo bias. diff --git a/README.md b/README.md index e3c491c..56b06ee 100644 --- a/README.md +++ b/README.md @@ -339,6 +339,23 @@ const response = await b.commit(); --- +### generateDocumentId() + +Generates a random ID matching Firestore's auto-generated document ID format: 20 characters from `[A-Za-z0-9]`, produced with rejection sampling via `crypto.getRandomValues` to avoid modulo bias. Ported from [`@firebase/firestore`'s `AutoId.newId()`](https://github.com/firebase/firebase-js-sdk/blob/main/packages/firestore/src/util/misc.ts). + +Useful when you need to know a document's ID before writing it — for example, to reference it from other documents in the same [`batch`](#batchdb). + +```typescript +const id = Firestore.generateDocumentId(); + +const b = Firestore.batch(db); +b.set(['todos', id], { title: 'Win the lottery', completed: false }); +b.set(['todo-index', id], { createdAt: Date.now() }); +await b.commit(); +``` + +--- + ## Error handling All operations reject with a `FirestoreError` when Firestore returns an error response or the network request fails. `FirestoreError` extends the built-in `Error`, so existing `try/catch` and `.message` checks keep working — but you can now branch on a stable string `code` instead of parsing the message. diff --git a/src/id.test.ts b/src/id.test.ts new file mode 100644 index 0000000..395c137 --- /dev/null +++ b/src/id.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; + +import { generateDocumentId } from './id'; + +describe('generateDocumentId', () => { + it('returns a 20-character string', () => { + const id = generateDocumentId(); + expect(id).toHaveLength(20); + }); + + it('only uses characters from [A-Za-z0-9]', () => { + for (let i = 0; i < 100; i++) { + expect(generateDocumentId()).toMatch(/^[A-Za-z0-9]{20}$/); + } + }); +}); diff --git a/src/id.ts b/src/id.ts new file mode 100644 index 0000000..2035e25 --- /dev/null +++ b/src/id.ts @@ -0,0 +1,31 @@ +const AUTO_ID_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; +const AUTO_ID_LENGTH = 20; +// Largest multiple of AUTO_ID_CHARS.length that fits in a byte. +// Bytes at or above this value are discarded so the modulo below is unbiased. +const MAX_MULTIPLE = Math.floor(256 / AUTO_ID_CHARS.length) * AUTO_ID_CHARS.length; +// Over-allocate to amortize rejected bytes: acceptance rate is ~97% (248/256), +// so 2× AUTO_ID_LENGTH almost always fills the ID in a single iteration. +const RANDOM_BYTES_PER_ITERATION = AUTO_ID_LENGTH * 2; + +/** + * Generates a random ID matching Firestore's auto-generated document ID format. + * 20 characters from [A-Za-z0-9], with rejection sampling to avoid modulo bias. + * Ported from `@firebase/firestore`'s `AutoId.newId()`. + */ +export const generateDocumentId = (): string => { + let id = ''; + + while (id.length < AUTO_ID_LENGTH) { + const bytes = new Uint8Array(RANDOM_BYTES_PER_ITERATION); + crypto.getRandomValues(bytes); + + for (let i = 0; i < bytes.length && id.length < AUTO_ID_LENGTH; i++) { + const byte = bytes[i]!; + if (byte < MAX_MULTIPLE) { + id += AUTO_ID_CHARS.charAt(byte % AUTO_ID_CHARS.length); + } + } + } + + return id; +}; diff --git a/src/index.ts b/src/index.ts index 3b1c187..d02bdd3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ export * from './batch'; export * from './create'; export { FirestoreError, type FirestoreErrorCode } from './error'; export * from './get'; +export * from './id'; export * from './init'; export * from './query'; export * from './remove';