Repository navigation
Expand file tree
/
Copy pathscrub-data.ts
More file actions
742 lines (649 loc) · 29 KB
/
Copy pathscrub-data.ts
File metadata and controls
742 lines (649 loc) · 29 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
/**
* scrub-data.ts — Public snapshot scrubber
*
* Walks the source gitsheets data repo, pseudonymizes all PII fields in
* Person records (names, slugs, github identities, slack handles), leaves
* Project/Buzz/membership records structurally intact (cross-references
* rewritten to pseudonymized slugs), and writes a single squashed orphan
* commit to the target repo.
*
* Usage:
* npm run -w apps/api script:scrub-data -- \
* --source=./codeforphilly-data \
* --target=./codeforphilly-data-snapshot \
* [--seed=2026-05-15] [--dry-run]
*
* PII safety rules (enforced by the mandatory verification pass at the end):
* - No real names in output
* - No email addresses anywhere
* - No githubLogin / githubUserId / slackSamlNameId in Person records
* - No real person slugs in any field
* - Record counts must match source
*
* Records are written through gitsheets sheet upserts in the target, so the
* snapshot gets each sheet's canonical serialization, path template and
* storage format (markdown for blog-posts). Attachments are copied, except
* on `people` (avatars are PII). A record that can't render its path — or
* would land on another record's path — fails the run instead of being
* silently collapsed.
*
* The whole snapshot is one parentless gitsheets transaction on
* `snapshot-<seed>-scrubbed`: it stages the source's `.gitsheets/` files
* (configs + vendored contracts) with `tx.writeFile`, then upserts the
* records, whose sheets read those staged configs. HEAD is pointed at the
* branch and a same-named tag added (`.github/workflows/snapshot.yml` pushes
* HEAD's branch).
*
* If verification fails the script exits non-zero and nothing is committed:
* the checks run inside the transaction, so a failure discards it.
*/
import { createHash } from 'node:crypto';
import { execFile } from 'node:child_process';
import { mkdir, readdir, readFile, rm } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { promisify } from 'node:util';
import { faker } from '@faker-js/faker';
import {
getFormat,
openRepo,
RECORD_PATH_KEY,
type BlobHandle,
type Repository,
type UpsertResult,
} from 'gitsheets';
import { SHEET_REFERENCES } from '@cfp/shared';
const exec = promisify(execFile);
// ---------------------------------------------------------------------------
// Wordlists for deterministic slug pseudonymization
// ---------------------------------------------------------------------------
export const ADJECTIVES = [
'amber', 'azure', 'bold', 'brave', 'bright', 'calm', 'clear', 'clever',
'cool', 'crisp', 'daring', 'deep', 'eager', 'fair', 'fast', 'firm',
'free', 'fresh', 'glad', 'gold', 'grand', 'great', 'green', 'happy',
'hardy', 'keen', 'kind', 'light', 'lively', 'loyal', 'mild', 'noble',
'open', 'proud', 'pure', 'quick', 'quiet', 'rapid', 'ready', 'rich',
'round', 'safe', 'sharp', 'smart', 'solid', 'still', 'strong', 'swift',
'tall', 'tidy', 'tough', 'true', 'vast', 'warm', 'wise', 'witty',
];
export const NOUNS = [
'anchor', 'arrow', 'badge', 'beacon', 'bear', 'bird', 'brook', 'canoe',
'cedar', 'cloud', 'comet', 'coral', 'crane', 'creek', 'dawn', 'deer',
'delta', 'dune', 'eagle', 'ember', 'falcon', 'fern', 'ferry', 'finch',
'flame', 'flint', 'forge', 'fox', 'frost', 'grove', 'hawk', 'heron',
'holly', 'horn', 'inlet', 'iris', 'ivy', 'jade', 'jay', 'kite',
'larch', 'lark', 'leaf', 'ledge', 'lime', 'lion', 'loon', 'lynx',
'maple', 'mare', 'mast', 'meadow', 'mesa', 'mint', 'mist', 'moose',
'moss', 'moth', 'oak', 'otter', 'owl', 'peak', 'pine', 'pond',
'quail', 'raven', 'reed', 'reef', 'ridge', 'robin', 'rock', 'rose',
'rush', 'sage', 'sand', 'seal', 'slate', 'snipe', 'sparrow', 'spruce',
'stag', 'stone', 'swan', 'thorn', 'tide', 'vale', 'vole', 'wave',
'wren', 'yarrow',
];
// ---------------------------------------------------------------------------
// Deterministic hashing
// ---------------------------------------------------------------------------
/** Deterministic unsigned 32-bit integer hash from a seed + input pair. */
export function deterministicHash(seed: string, input: string): number {
const h = createHash('sha256').update(`${seed}:${input}`).digest();
const byte0 = h[0] ?? 0;
const byte1 = h[1] ?? 0;
const byte2 = h[2] ?? 0;
const byte3 = h[3] ?? 0;
return ((byte0 << 24) | (byte1 << 16) | (byte2 << 8) | byte3) >>> 0;
}
// ---------------------------------------------------------------------------
// Slug pseudonymization
// ---------------------------------------------------------------------------
/**
* Build a bidirectional slug map from realSlug → pseudoSlug.
* Deterministic: same seed + same set of slugs = same output.
* Collision-safe: appends a numeric suffix when two real slugs would map
* to the same pseudo-slug.
*/
export function buildSlugMap(realSlugs: readonly string[], seed: string): Map<string, string> {
const realToFake = new Map<string, string>();
const usedFake = new Set<string>();
for (const realSlug of realSlugs) {
const h = deterministicHash(seed, realSlug);
const adj = ADJECTIVES[h % ADJECTIVES.length];
const nounH = deterministicHash(seed + ':noun', realSlug);
const noun = NOUNS[nounH % NOUNS.length];
let candidate = `${adj}-${noun}`;
let suffix = 0;
while (usedFake.has(candidate)) {
suffix++;
candidate = `${adj}-${noun}-${suffix}`;
}
usedFake.add(candidate);
realToFake.set(realSlug, candidate);
}
return realToFake;
}
// ---------------------------------------------------------------------------
// @mention rewriting in markdown text
// ---------------------------------------------------------------------------
/**
* Rewrite every field that references a person by slug (memberships,
* help-wanted interest, RSVPs, evaluations — the references the schemas
* declare, specs/data-model.md → References) to that person's pseudonymized
* slug, so the reference still resolves and the record's path follows.
*/
export function rewritePersonReferences(
sheetName: string,
record: Record<string, unknown>,
slugMap: Map<string, string>,
): Record<string, unknown> {
let out = record;
for (const ref of SHEET_REFERENCES) {
if (ref.sheet !== sheetName || ref.target.sheet !== 'people' || ref.target.field !== 'slug') continue;
const value = out[ref.field];
const pseudo = typeof value === 'string' ? slugMap.get(value) : undefined;
if (pseudo !== undefined) out = { ...out, [ref.field]: pseudo };
}
return out;
}
/**
* Replace `@mention` occurrences of real person slugs in markdown-like text.
*/
export function rewriteMentions(text: string, slugMap: Map<string, string>): string {
return text.replace(/@([a-z0-9][a-z0-9-]{1,49})/g, (_match, slug: string) => {
const pseudo = slugMap.get(slug);
return pseudo !== undefined ? `@${pseudo}` : `@${slug}`;
});
}
// ---------------------------------------------------------------------------
// Record scrubbers
// ---------------------------------------------------------------------------
/**
* Scrub a Person record. All PII fields are replaced with pseudonyms.
* The UUID (`id`) is kept unchanged — cross-references rely on UUIDs, not slugs.
*/
export function scrubPersonRecord(
record: Record<string, unknown>,
slugMap: Map<string, string>,
fakerInstance: typeof faker,
): Record<string, unknown> {
const realSlug = typeof record['slug'] === 'string' ? record['slug'] : '';
const pseudoSlug = slugMap.get(realSlug) ?? realSlug;
const scrubbed: Record<string, unknown> = { ...record };
// Pseudonymize human-facing identity fields
scrubbed['slug'] = pseudoSlug;
scrubbed['fullName'] = fakerInstance.person.fullName();
scrubbed['firstName'] = fakerInstance.person.firstName();
scrubbed['lastName'] = fakerInstance.person.lastName();
// Replace bio with lorem ipsum
if (scrubbed['bio'] !== null && scrubbed['bio'] !== undefined) {
scrubbed['bio'] = fakerInstance.lorem.paragraph();
}
// Replace slackHandle with pseudo slug (Slack handle format is compatible)
if (scrubbed['slackHandle'] !== null && scrubbed['slackHandle'] !== undefined) {
scrubbed['slackHandle'] = pseudoSlug;
}
// Clear github identity — these are real identifiers, not for public snapshot
delete scrubbed['githubLogin'];
delete scrubbed['githubUserId'];
delete scrubbed['githubLinkedAt'];
// Clear SAML continuity field — not applicable to snapshot users
delete scrubbed['slackSamlNameId'];
// Clear avatar — person attachments (photos of real people) never follow
// the snapshot; see SHEETS_WITHOUT_ATTACHMENTS
delete scrubbed['avatarKey'];
return scrubbed;
}
/**
* Scrub a Project record. Project content is public-by-design; we leave it
* intact (attachments and `featuredImageKey` included) but rewrite any
* @slug mentions in text fields.
*/
export function scrubProjectRecord(
record: Record<string, unknown>,
slugMap: Map<string, string>,
): Record<string, unknown> {
const scrubbed: Record<string, unknown> = { ...record };
// Rewrite @mentions in text fields
if (typeof scrubbed['overview'] === 'string') {
scrubbed['overview'] = rewriteMentions(scrubbed['overview'], slugMap);
}
if (typeof scrubbed['summary'] === 'string') {
scrubbed['summary'] = rewriteMentions(scrubbed['summary'], slugMap);
}
// chatChannel might contain a person's username — normalize to chat-<projectSlug>
if (scrubbed['chatChannel'] !== null && scrubbed['chatChannel'] !== undefined) {
const projectSlug = typeof scrubbed['slug'] === 'string' ? scrubbed['slug'] : 'project';
scrubbed['chatChannel'] = `chat-${projectSlug}`;
}
return scrubbed;
}
/**
* Scrub a ProjectBuzz record. Headline and URL are public; summary may have
* @mentions. Image attachments follow the record, so `imageKey` stays.
*/
export function scrubProjectBuzzRecord(
record: Record<string, unknown>,
slugMap: Map<string, string>,
): Record<string, unknown> {
const scrubbed: Record<string, unknown> = { ...record };
if (typeof scrubbed['summary'] === 'string') {
scrubbed['summary'] = rewriteMentions(scrubbed['summary'], slugMap);
}
if (typeof scrubbed['headline'] === 'string') {
scrubbed['headline'] = rewriteMentions(scrubbed['headline'], slugMap);
}
return scrubbed;
}
// ---------------------------------------------------------------------------
// Verification pass
// ---------------------------------------------------------------------------
export interface VerificationResult {
passed: boolean;
errors: string[];
}
/** A serialized record file: repo-relative (or absolute) path + text. */
export interface SnapshotFile {
readonly path: string;
readonly content: string;
}
/** Record file extensions: TOML records and markdown (content-typed) records. */
const RECORD_FILE = /\.(toml|md)$/;
/** Walk a directory tree returning all record file paths (.toml / .md). */
async function walkRecordFiles(dir: string): Promise<string[]> {
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
const files: string[] = [];
for (const entry of entries) {
if (entry.name.startsWith('.')) continue;
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...(await walkRecordFiles(fullPath)));
} else if (RECORD_FILE.test(entry.name)) {
files.push(fullPath);
}
}
return files;
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Verify serialized record files contain no PII.
* Checks for email patterns, real slugs, and sensitive identity fields.
*/
export function verifySnapshotFiles(
files: Iterable<SnapshotFile>,
realSlugs: ReadonlySet<string>,
): VerificationResult {
const errors: string[] = [];
const slugPatterns = [...realSlugs].map((realSlug) => ({
realSlug,
// Match slug as a standalone value (quoted string or unquoted)
pattern: new RegExp(`(?:^|[\\s='"\\[/])${escapeRegex(realSlug)}(?:[\\s'"\\]/]|$)`, 'm'),
}));
for (const { path: filePath, content } of files) {
// Rule 1: No email addresses (catches user@example.com patterns)
const emailPattern = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
const emailMatches = content.match(emailPattern);
if (emailMatches) {
errors.push(`Email-like pattern found in ${filePath}: ${emailMatches.join(', ')}`);
}
// Rule 2: No real person slugs in any file
for (const { realSlug, pattern } of slugPatterns) {
if (pattern.test(content)) {
errors.push(`Real slug "${realSlug}" found in ${filePath}`);
}
}
// Rule 3: Person records must not contain github identity fields with values
if (`/${filePath}`.includes('/people/')) {
const sensitiveFields = ['githubLogin', 'githubUserId', 'slackSamlNameId'];
for (const field of sensitiveFields) {
const fieldPattern = new RegExp(`^${field}\\s*=\\s*(?!null)[^\\n]+`, 'm');
if (fieldPattern.test(content)) {
errors.push(`Sensitive field "${field}" found with value in ${filePath}`);
}
}
}
}
return { passed: errors.length === 0, errors };
}
/** Verify a snapshot directory's record files (.toml / .md) contain no PII. */
export async function verifySnapshot(
targetPath: string,
realSlugs: ReadonlySet<string>,
): Promise<VerificationResult> {
const files: SnapshotFile[] = [];
for (const filePath of await walkRecordFiles(targetPath)) {
files.push({ path: filePath, content: await readFile(filePath, 'utf-8') });
}
return verifySnapshotFiles(files, realSlugs);
}
// ---------------------------------------------------------------------------
// Core scrub function (exported for testing)
// ---------------------------------------------------------------------------
export interface ScrubOptions {
/** Absolute path to the source gitsheets repo. */
source: string;
/** Absolute path to the target snapshot repo (will be init'd if absent). */
target: string;
/** Pseudonymization seed. Same seed → byte-identical output. */
seed: string;
/** If true, validate + report but do not write anything. */
dryRun?: boolean;
}
export interface ScrubResult {
/** Number of records processed per sheet name. */
sourceCounts: Record<string, number>;
/** SHA-1 of the resulting git commit, or null if dry-run / nothing changed. */
commitHash: string | null;
/** Name of the snapshot branch/tag, or null if dry-run. */
branchName: string | null;
}
type GitRunner = (...args: string[]) => Promise<{ stdout: string; stderr: string }>;
const SNAPSHOT_AUTHOR = { name: 'Code for Philly', email: 'snapshot@users.noreply.codeforphilly.org' };
/**
* Sheets whose attachments never follow a record into the snapshot. Person
* attachments are avatars — photos of real people — so they are PII.
*/
const SHEETS_WITHOUT_ATTACHMENTS: ReadonlySet<string> = new Set(['people']);
interface ScrubbedRecord {
readonly sheetName: string;
readonly record: Record<string, unknown>;
/** Source attachments to copy, or null when the record has none / they're dropped. */
readonly attachments: Record<string, BlobHandle> | null;
}
/**
* Open the target repo, `git init`-ing it first when absent. Raw git:
* gitsheets opens existing repositories but has no init.
*/
async function initOrOpenTargetRepo(targetPath: string): Promise<GitRunner> {
await mkdir(targetPath, { recursive: true });
const git: GitRunner = (...args) => exec('git', args, { cwd: targetPath });
try {
await git('rev-parse', '--git-dir');
} catch {
await git('init', '-b', 'main');
await git('config', 'user.email', SNAPSHOT_AUTHOR.email);
await git('config', 'user.name', SNAPSHOT_AUTHOR.name);
await git('config', 'commit.gpgsign', 'false');
}
return git;
}
/**
* Every file path under `dir` at `ref`, recursively (gitsheets `listTree`
* lists one level).
*/
async function listFilesUnder(repo: Repository, ref: string, dir: string): Promise<string[]> {
const entries = (await repo.listTree(ref, dir)) ?? [];
const out: string[] = [];
for (const entry of entries) {
const path = `${dir}/${entry.name}`;
if (entry.type === 'tree') out.push(...(await listFilesUnder(repo, ref, path)));
else if (entry.type === 'blob') out.push(path);
}
return out;
}
/**
* Run the full snapshot scrub. Returns a ScrubResult.
* Throws (with a descriptive message) if verification fails or counts mismatch.
*/
export async function scrubRepo(opts: ScrubOptions): Promise<ScrubResult> {
const { source, target, seed, dryRun = false } = opts;
// -------------------------------------------------------------------------
// 1. Open source repo
// -------------------------------------------------------------------------
const sourceRepo = await openRepo({ gitDir: source });
const sourceHeadHash = await sourceRepo.resolveRef('HEAD');
// -------------------------------------------------------------------------
// 2. Discover all sheets
// -------------------------------------------------------------------------
const sheets = await sourceRepo.openSheets();
// -------------------------------------------------------------------------
// 3. Collect all Person slugs
// -------------------------------------------------------------------------
const allPersonSlugs: string[] = [];
const personSheet = sheets['people'];
if (personSheet !== undefined) {
for await (const record of personSheet.query()) {
const slug = record['slug'];
if (typeof slug === 'string') allPersonSlugs.push(slug);
}
}
const slugMap = buildSlugMap(allPersonSlugs, seed);
// -------------------------------------------------------------------------
// 4. Per-person faker seeding (deterministic)
// -------------------------------------------------------------------------
function fakerForSlug(realSlug: string): typeof faker {
const personSeed = deterministicHash(seed, `person:${realSlug}`);
faker.seed(personSeed);
return faker;
}
// -------------------------------------------------------------------------
// 5. Scrub all records
// -------------------------------------------------------------------------
const sourceCounts: Record<string, number> = {};
const scrubbedRecords: ScrubbedRecord[] = [];
for (const [sheetName, sheet] of Object.entries(sheets)) {
let count = 0;
for await (const record of sheet.query()) {
count++;
const rec = record as Record<string, unknown>;
let scrubbed: Record<string, unknown>;
if (sheetName === 'people') {
const realSlug = typeof rec['slug'] === 'string' ? rec['slug'] : '';
scrubbed = scrubPersonRecord(rec, slugMap, fakerForSlug(realSlug));
} else if (sheetName === 'projects') {
scrubbed = scrubProjectRecord(rec, slugMap);
} else if (sheetName === 'project-buzz') {
scrubbed = scrubProjectBuzzRecord(rec, slugMap);
} else {
scrubbed = { ...rec };
for (const [k, v] of Object.entries(scrubbed)) {
if (typeof v === 'string' && v.includes('@')) {
scrubbed[k] = rewriteMentions(v, slugMap);
}
}
}
scrubbed = rewritePersonReferences(sheetName, scrubbed, slugMap);
// Look attachments up by the record's source path (as read), not a
// re-render of its template.
const sourcePath = (record as Record<PropertyKey, unknown>)[RECORD_PATH_KEY];
const attachments = SHEETS_WITHOUT_ATTACHMENTS.has(sheetName)
? null
: await sheet.getAttachments(typeof sourcePath === 'string' ? sourcePath : record);
scrubbedRecords.push({ sheetName, record: scrubbed, attachments });
}
sourceCounts[sheetName] = count;
}
if (dryRun) {
return { sourceCounts, commitHash: null, branchName: null };
}
// -------------------------------------------------------------------------
// 6. Write the snapshot through gitsheets: one parentless transaction
// stages the `.gitsheets/` files first (its sheets read configs from the
// transaction's own tree), then upserts every scrubbed record (canonical
// serialization, path templates, content-typed formats) and copies
// attachments. Count parity and verification (steps 7 and 8) run inside
// the handler, so a failure throws before anything commits.
// -------------------------------------------------------------------------
const git = await initOrOpenTargetRepo(target);
// Raw git: resolves a non-bare target's `.git` for openRepo.
const targetGitDir = (await git('rev-parse', '--absolute-git-dir')).stdout.trim();
const targetRepo = await openRepo({ gitDir: targetGitDir });
// Everything under the source's `.gitsheets/`: sheet configs plus the
// vendored schema contracts (`contracts/…`) that configs may declare.
const sourceRef = sourceHeadHash ?? 'HEAD';
const configPaths = await listFilesUnder(sourceRepo, sourceRef, '.gitsheets');
const configs = await Promise.all(
configPaths.map(async (path) => ({ path, content: await (await sourceRepo.readBlob(sourceRef, path)).read() })),
);
const timestamp = new Date().toISOString();
const branchName = `snapshot-${seed.replace(/[^a-z0-9]/gi, '-')}-scrubbed`;
const commitMessage = `snapshot: anonymized data export from ${timestamp}
Generated by apps/api/scripts/scrub-data.ts.
Source revision: ${sourceHeadHash ?? 'unknown'}
Seed: ${seed}
This snapshot is intended for contributor onboarding. It contains no
real names, no emails, no GitHub identities. All personal data has
been pseudonymized via the documented scrubbing rules.
See apps/api/scripts/scrub-data.ts and specs/behaviors/storage.md.`;
// An orphan transaction creates its branch and refuses an existing one.
// A same-seed re-run (the branch exists) commits detached instead and
// moves the branch below only if the tree changed.
const existing = await targetRepo.resolveRef(`refs/heads/${branchName}`);
const writtenFiles: SnapshotFile[] = [];
const scrubCounts: Record<string, number> = {};
const snapshot = await targetRepo.transact(
{
parent: null,
...(existing === null ? { branch: branchName } : {}),
message: commitMessage,
author: SNAPSHOT_AUTHOR,
},
async (tx) => {
for (const { path, content } of configs) tx.writeFile(path, content);
const pathsBySheet = new Map<string, Map<string, unknown>>();
for (const { sheetName, record, attachments } of scrubbedRecords) {
const sheet = tx.sheet(sheetName);
let result: UpsertResult;
try {
result = await sheet.upsert(record, { allowMissingBody: true });
} catch (err) {
throw new Error(
`Sheet "${sheetName}": cannot write record ${describeRecord(record)}: ${err instanceof Error ? err.message : String(err)}`,
{ cause: err },
);
}
let paths = pathsBySheet.get(sheetName);
if (!paths) pathsBySheet.set(sheetName, (paths = new Map()));
if (paths.has(result.path)) {
throw new Error(
`Sheet "${sheetName}": records ${describeRecord(paths.get(result.path))} and ${describeRecord(record)} both render to path "${result.path}"`,
);
}
paths.set(result.path, record);
scrubCounts[sheetName] = (scrubCounts[sheetName] ?? 0) + 1;
// UpsertResult.path is sheet-relative and extensionless; verification
// wants the repo path (e.g. people/<slug>.toml, blog-posts/<slug>.md).
const config = await sheet.getCachedConfig();
const repoPath = `${config.root === '.' ? '' : `${config.root}/`}${result.path}${getFormat(config.format.type).extension}`;
writtenFiles.push({ path: repoPath, content: (await result.blob.read()).toString('utf-8') });
if (attachments !== null && Object.keys(attachments).length > 0) {
const bytes: Record<string, Buffer> = {};
for (const [name, handle] of Object.entries(attachments)) bytes[name] = await handle.read();
await sheet.setAttachments(record, bytes);
}
}
// ---------------------------------------------------------------------
// 7. Count validation — a record that collapsed onto another's path,
// or failed to write, shows up here.
// ---------------------------------------------------------------------
const countMismatches: string[] = [];
for (const [sheetName, sourceCount] of Object.entries(sourceCounts)) {
const scrubCount = scrubCounts[sheetName] ?? 0;
if (sourceCount !== scrubCount) {
countMismatches.push(`Sheet "${sheetName}": source=${sourceCount} scrubbed=${scrubCount}`);
}
}
if (countMismatches.length > 0) {
throw new Error(`Record count mismatch:\n${countMismatches.join('\n')}`);
}
// ---------------------------------------------------------------------
// 8. Verification pass (before anything commits)
// ---------------------------------------------------------------------
const verification = verifySnapshotFiles(writtenFiles, new Set(allPersonSlugs));
if (!verification.passed) {
throw new Error(`Verification failed:\n${verification.errors.join('\n')}`);
}
},
);
const commitHash = snapshot.commitHash;
if (commitHash === null) throw new Error('scrub-data: internal: snapshot transaction produced no commit');
// -------------------------------------------------------------------------
// 9. Point the branch, HEAD and tag at the snapshot.
// -------------------------------------------------------------------------
if (existing !== null) {
// Same-seed re-run: an unchanged tree is a no-op (the detached commit is
// left unreferenced). Equal root listings mean equal trees.
const [before, after] = await Promise.all([
targetRepo.listTree(existing),
targetRepo.listTree(commitHash),
]);
if (JSON.stringify(before) === JSON.stringify(after)) {
return { sourceCounts, commitHash: null, branchName };
}
// Raw git: gitsheets can't repoint an existing branch at an unrelated
// commit (an orphan transact refuses an existing branch).
await git('update-ref', `refs/heads/${branchName}`, commitHash);
}
// Raw git: gitsheets never moves HEAD. The snapshot workflow pushes the
// branch HEAD is on, so HEAD must point at the new branch.
await git('symbolic-ref', 'HEAD', `refs/heads/${branchName}`);
await targetRepo.tag(branchName, commitHash, { force: true });
// Bring a non-bare target's working tree in line with the new HEAD. Raw
// git: gitsheets works bare-style and never touches a working tree.
if ((await git('rev-parse', '--is-bare-repository')).stdout.trim() !== 'true') {
for (const entry of await readdir(target, { withFileTypes: true })) {
if (entry.name === '.git') continue;
await rm(join(target, entry.name), { recursive: true, force: true });
}
await git('reset', '--quiet', '--hard', 'HEAD');
}
return { sourceCounts, commitHash, branchName };
}
function describeRecord(record: unknown): string {
const r = (record ?? {}) as Record<string, unknown>;
const key = r['id'] ?? r['slug'];
return typeof key === 'string' ? `"${key}"` : JSON.stringify(record);
}
// ---------------------------------------------------------------------------
// CLI argument parsing
// ---------------------------------------------------------------------------
function parseArgs(argv: string[]): ScrubOptions {
const args: Record<string, string | boolean> = {};
for (const arg of argv) {
if (arg.startsWith('--')) {
const eqIdx = arg.indexOf('=');
if (eqIdx === -1) {
args[arg.slice(2)] = true;
} else {
args[arg.slice(2, eqIdx)] = arg.slice(eqIdx + 1);
}
}
}
const source = typeof args['source'] === 'string' ? args['source'] : '';
const target = typeof args['target'] === 'string' ? args['target'] : '';
if (!source || !target) {
process.stderr.write('Usage: scrub-data --source=<path> --target=<path> [--seed=<string>] [--dry-run]\n');
process.exit(1);
}
const seed =
typeof args['seed'] === 'string'
? args['seed']
: (new Date().toISOString().split('T')[0] ?? '2026-01-01');
return {
source: resolve(source),
target: resolve(target),
seed,
dryRun: args['dry-run'] === true,
};
}
// ---------------------------------------------------------------------------
// Entry point (only runs when invoked directly, not when imported in tests)
// ---------------------------------------------------------------------------
// Check if this file is the main module
const isMain = process.argv[1] !== undefined && import.meta.url.endsWith(process.argv[1].replace(/\\/g, '/'));
if (isMain) {
const opts = parseArgs(process.argv.slice(2));
console.log(
`scrub-data: source=${opts.source} target=${opts.target} seed=${opts.seed} dry-run=${opts.dryRun ?? false}`,
);
scrubRepo(opts)
.then((result) => {
console.log('Source counts:', result.sourceCounts);
if (result.commitHash) {
console.log(`Committed: ${result.commitHash} on branch ${result.branchName ?? '?'}`);
}
console.log('Done.');
})
.catch((err: unknown) => {
console.error('Error:', err instanceof Error ? err.message : String(err));
process.exit(1);
});
}