Skip to content

Commit bfead48

Browse files
committed
feat(cli): migrate sign up documents to auto-id
1 parent aeb8543 commit bfead48

7 files changed

Lines changed: 187 additions & 3 deletions

File tree

‎docs/tutorials/firebase-utils.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ Commit the change to `site.json`, check the schedule and deploy. Then delete the
7676

7777
Also delete the `config/schedule` document. `schedule.published` in `site.json` replaces it.
7878

79+
`4.0.0-sign-up-ids` runs when a `subscribers` or `potentialPartners` document has the email as its ID. Before v4, the site saved sign-ups with the email, without its punctuation, as the ID, so the email showed in every link to them. The migration copies each one to a random ID, as the site writes them now, and deletes the old document in the same write. The plan shows only how many move, not the emails.
80+
7981
### Backups and undo
8082

8183
Before it changes a document, `--fix` saves it as it was in `.firebase/backups/<date>/documents.json`. Git ignores the folder. To undo a fix:

‎packages/cli/src/lib/firestore-fix.test.ts‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,21 @@ const fakeFirestore = (stale: string[] = []) => {
4242
set: async (...args: unknown[]) => writes.push(['set', path, ...args]),
4343
delete: async (...args: unknown[]) => writes.push(['delete', path, ...args]),
4444
}),
45+
batch: () => {
46+
const operations: unknown[][] = [];
47+
return {
48+
create: (ref: { path: string }, data: unknown) =>
49+
operations.push(['create', ref.path, data]),
50+
delete: (ref: { path: string }, precondition: unknown) =>
51+
operations.push(['delete', ref.path, precondition]),
52+
commit: async () => {
53+
if (operations.some(([, path]) => stale.includes(path as string))) {
54+
throw Object.assign(new Error('stale'), { code: 9 });
55+
}
56+
writes.push(['batch', ...operations]);
57+
},
58+
};
59+
},
4560
} as unknown as Firestore;
4661
return { firestore, writes };
4762
};
@@ -96,6 +111,45 @@ describe('planFixes', () => {
96111
});
97112

98113
describe('applyFixes', () => {
114+
it('moves sign-ups off email IDs in one batch each, and backs them up', async () => {
115+
const repo = makeRepo();
116+
const { firestore, writes } = fakeFirestore(['subscribers/gracecompanycom']);
117+
const ada = { email: 'ada@example.com', firstName: '', lastName: '' };
118+
const grace = { email: 'grace@company.com', firstName: '', lastName: '' };
119+
const documents = [
120+
doc('subscribers/adaexamplecom', ada),
121+
doc('subscribers/gracecompanycom', grace),
122+
];
123+
const backupFolder = join(repo, 'backup');
124+
const plan = planFixes(documents, 'UTC');
125+
const [toAda] = plan.migrations[0]!.plan.moves!.map(({ to }) => to);
126+
127+
const result = await applyFixes({
128+
firestore,
129+
plan,
130+
documents,
131+
list: async () => documents,
132+
timeZone: 'UTC',
133+
repoRoot: repo,
134+
backupFolder,
135+
});
136+
137+
expect(result.skipped).toEqual(['subscribers/gracecompanycom']);
138+
expect(writes).toEqual([
139+
[
140+
'batch',
141+
['create', toAda, ada],
142+
['delete', 'subscribers/adaexamplecom', { lastUpdateTime: at }],
143+
],
144+
]);
145+
const backup = JSON.parse(readFileSync(join(backupFolder, 'documents.json'), 'utf8'));
146+
expect(Object.keys(backup.documents)).toEqual([
147+
'subscribers/adaexamplecom',
148+
'subscribers/gracecompanycom',
149+
]);
150+
expect(backup.created).toHaveLength(2);
151+
});
152+
99153
it('sets a missing updatedAt to the server time', async () => {
100154
const repo = makeRepo();
101155
const { firestore, writes } = fakeFirestore();

‎packages/cli/src/lib/firestore-fix.ts‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -184,10 +184,13 @@ export const applyFixes = async ({
184184
const byPath = new Map(documents.map((document) => [document.path, document]));
185185

186186
for (const { migration, plan: migrationPlan } of plan.migrations) {
187+
const moves = migrationPlan.moves ?? [];
187188
addToBackup(
188189
backupFolder,
189-
migrationPlan.updates.flatMap(({ path }) => byPath.get(path) ?? []),
190-
migrationPlan.creates.map(({ path }) => path),
190+
[...migrationPlan.updates, ...moves.map(({ from }) => ({ path: from }))].flatMap(
191+
({ path }) => byPath.get(path) ?? [],
192+
),
193+
[...migrationPlan.creates.map(({ path }) => path), ...moves.map(({ to }) => to)],
191194
);
192195
const before = { written: result.written, skipped: result.skipped.length };
193196
for (const { path, fields } of migrationPlan.updates) {
@@ -202,6 +205,20 @@ export const applyFixes = async ({
202205
result.skipped.push(path);
203206
}
204207
}
208+
for (const { from, to } of moves) {
209+
const document = byPath.get(from)!;
210+
// One batch, so a document changed since the check is neither copied nor deleted.
211+
const batch = firestore.batch();
212+
batch.create(firestore.doc(to), document.raw!);
213+
batch.delete(firestore.doc(from), { lastUpdateTime: document.updateTime as Timestamp });
214+
try {
215+
await batch.commit();
216+
result.written++;
217+
} catch (error) {
218+
if (code(error) !== FAILED_PRECONDITION && code(error) !== ALREADY_EXISTS) throw error;
219+
result.skipped.push(from);
220+
}
221+
}
205222
if (migrationPlan.site) writeSite(repoRoot, migrationPlan.site);
206223
// A migration that skipped documents stays pending, so --fix runs it again.
207224
if (result.skipped.length > before.skipped) continue;
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { autoId, signUpIds } from './4.0.0-sign-up-ids.js';
3+
4+
const subscriber = { email: 'ada@example.com', firstName: 'Ada', lastName: '' };
5+
const documents = [
6+
{ path: 'subscribers/adaexamplecom', data: subscriber },
7+
{ path: 'subscribers/ada@example.com', data: subscriber },
8+
{ path: 'subscribers/Xk3PqLm9TzR2aB7cD1eF', data: subscriber },
9+
{
10+
path: 'potentialPartners/gracecompanycom',
11+
data: { companyName: '', email: 'grace@company.com', fullName: '' },
12+
},
13+
{ path: 'speakers/adaexamplecom', data: { email: 'ada@example.com' } },
14+
];
15+
16+
describe('4.0.0-sign-up-ids', () => {
17+
it('is pending while a sign-up has its email, or its email without punctuation, as its ID', () => {
18+
expect(signUpIds.pending(documents)).toBe(
19+
'subscribers: 2 documents, potentialPartners: 1 document with the email as the ID.',
20+
);
21+
expect(signUpIds.pending(documents.slice(2))).toBe(
22+
'potentialPartners: 1 document with the email as the ID.',
23+
);
24+
expect(signUpIds.pending([documents[2]!, documents[4]!])).toBeUndefined();
25+
});
26+
27+
it('moves them to random IDs in the same collection, without showing the emails', () => {
28+
const plan = signUpIds.plan(documents);
29+
30+
expect(plan.moves?.map(({ from }) => from)).toEqual([
31+
'subscribers/adaexamplecom',
32+
'subscribers/ada@example.com',
33+
'potentialPartners/gracecompanycom',
34+
]);
35+
expect(plan.moves?.map(({ to }) => to)).toEqual([
36+
expect.stringMatching(/^subscribers\/[A-Za-z0-9]{20}$/),
37+
expect.stringMatching(/^subscribers\/[A-Za-z0-9]{20}$/),
38+
expect.stringMatching(/^potentialPartners\/[A-Za-z0-9]{20}$/),
39+
]);
40+
expect(plan.lines).toEqual([
41+
'subscribers: 2 documents move to random IDs',
42+
'potentialPartners: 1 document moves to random IDs',
43+
]);
44+
expect(JSON.stringify(plan.lines)).not.toMatch(/ada|grace/);
45+
});
46+
47+
it('makes IDs like addDoc does', () => {
48+
expect(new Set(Array.from({ length: 100 }, autoId)).size).toBe(100);
49+
});
50+
});
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { randomInt } from 'crypto';
2+
import type { FirestoreDocument, Migration } from './types.js';
3+
4+
const COLLECTIONS = ['subscribers', 'potentialPartners'];
5+
6+
const plural = (amount: number, noun: string) => `${amount} ${noun}${amount === 1 ? '' : 's'}`;
7+
8+
const AUTO_ID_CHARACTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
9+
10+
/** A random ID like the ones Firestore's `addDoc` makes. */
11+
export const autoId = () =>
12+
Array.from({ length: 20 }, () => AUTO_ID_CHARACTERS[randomInt(AUTO_ID_CHARACTERS.length)]).join(
13+
'',
14+
);
15+
16+
/** Sign-ups whose ID is their email, or their email without punctuation as older sites wrote. */
17+
const emailIds = (documents: FirestoreDocument[]) =>
18+
documents.filter(({ path, data }) => {
19+
const [collection, id, ...rest] = path.split('/');
20+
if (rest.length || !COLLECTIONS.includes(collection!) || typeof data?.['email'] !== 'string') {
21+
return false;
22+
}
23+
return id === data['email'] || id === data['email'].replace(/[^\w\s]/gi, '');
24+
});
25+
26+
const counts = (documents: FirestoreDocument[], verb = '') =>
27+
COLLECTIONS.flatMap((collection) => {
28+
const amount = documents.filter(({ path }) => path.startsWith(`${collection}/`)).length;
29+
const suffix = verb && ` ${verb}${amount === 1 ? 's' : ''}`;
30+
return amount ? [`${collection}: ${plural(amount, 'document')}${suffix}`] : [];
31+
});
32+
33+
/**
34+
* Before v4, the site saved sign-ups with the email as the document ID, so the email showed in
35+
* every link to them. This moves them to random IDs, as the site writes them now.
36+
*/
37+
export const signUpIds: Migration = {
38+
id: '4.0.0-sign-up-ids',
39+
description: 'Move sign-ups whose document ID is their email to random IDs.',
40+
reads: [],
41+
42+
pending: (documents) => {
43+
const found = emailIds(documents);
44+
return found.length ? `${counts(found).join(', ')} with the email as the ID.` : undefined;
45+
},
46+
47+
plan: (documents) => {
48+
const found = emailIds(documents);
49+
return {
50+
updates: [],
51+
creates: [],
52+
moves: found.map(({ path }) => ({ from: path, to: `${path.split('/')[0]}/${autoId()}` })),
53+
// The IDs are emails, so only the counts are shown.
54+
lines: counts(found, 'move').map((line) => `${line} to random IDs`),
55+
warnings: [],
56+
};
57+
},
58+
};

‎packages/cli/src/migrations/index.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import type { FirestoreDocument } from './types.js';
22
import { scheduleOnSessions } from './4.0.0-schedule-on-sessions.js';
3+
import { signUpIds } from './4.0.0-sign-up-ids.js';
34

45
export type { FirestoreDocument, Migration, MigrationPlan } from './types.js';
56

67
/** Every data migration, oldest first. */
7-
export const MIGRATIONS = [scheduleOnSessions];
8+
export const MIGRATIONS = [scheduleOnSessions, signUpIds];
89

910
/**
1011
* The migrations the data still needs, with what shows it. The data decides, not

‎packages/cli/src/migrations/types.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ export interface MigrationPlan {
1313
updates: { path: string; fields: Record<string, unknown> }[];
1414
/** New documents, which must not exist yet. */
1515
creates: { path: string; data: Record<string, unknown> }[];
16+
/** Documents to copy to a new path, then delete. */
17+
moves?: { from: string; to: string }[];
1618
/** A change to packages/config/site.json. */
1719
site?: (site: Record<string, unknown>) => Record<string, unknown>;
1820
/** What the migration changes, one line each. */

0 commit comments

Comments
 (0)