Skip to content

Commit fec376e

Browse files
Merge pull request #151 from CodeForPhilly/chore/gitsheets-2x-bump
chore(deps): upgrade gitsheets to 2.x (Rust core, #150)
2 parents ca3bcb3 + 5e5e219 commit fec376e

7 files changed

Lines changed: 393 additions & 1072 deletions

File tree

‎apps/api/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
"bcryptjs": "^3.0.3",
3636
"better-sqlite3": "^12.10.0",
3737
"fastify": "^5.8.5",
38-
"gitsheets": "^1.4.1",
38+
"gitsheets": "^2.2.0",
3939
"jose": "^6.2.3",
4040
"resend": "^6.12.4",
4141
"samlify": "^2.13.0",

‎apps/api/scripts/import-laddr/importer.ts‎

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ import {
9292
type TranslateCtx,
9393
type Warnings,
9494
} from './translators.js';
95-
import { BlobObject } from 'hologit';
95+
import type { BlobHandle } from 'gitsheets';
9696

9797
// ---------------------------------------------------------------------------
9898
// Public types
@@ -187,7 +187,7 @@ export async function importLaddrFromJson(opts: ImportOptions): Promise<ImportRe
187187
// -------------------------------------------------------------------------
188188
let store: PublicStore | null = null;
189189
// Gitsheets Repository — needed to write attachment blobs via
190-
// BlobObject.write into the underlying git object DB.
190+
// repo.writeBlob into the underlying git object DB.
191191
let publicRepo: Awaited<ReturnType<typeof openPublicStore>>['repo'] | null = null;
192192
let existingIds: ExistingIds;
193193

@@ -500,7 +500,6 @@ export async function importLaddrFromJson(opts: ImportOptions): Promise<ImportRe
500500
if (publicRepo === null) {
501501
throw new Error('[import-laddr] internal: publicRepo not opened');
502502
}
503-
const hologit = publicRepo.hologitRepo;
504503

505504
log(`[import] clear + upsert tags (${tags.length})`);
506505
await tx.tags.clear();
@@ -513,8 +512,8 @@ export async function importLaddrFromJson(opts: ImportOptions): Promise<ImportRe
513512
if (avatar) {
514513
// Mirror POST /api/people/:slug/avatar: store original + 128 thumb
515514
// as attachments and point avatarKey at the conventional path.
516-
const originalBlob = await BlobObject.write(hologit, avatar.original as unknown as string);
517-
const thumbnailBlob = await BlobObject.write(hologit, avatar.thumbnail as unknown as string);
515+
const originalBlob = await publicRepo.writeBlob(avatar.original);
516+
const thumbnailBlob = await publicRepo.writeBlob(avatar.thumbnail);
518517
await tx.people.setAttachments(p, {
519518
'avatar.jpg': originalBlob,
520519
'avatar-128.jpg': thumbnailBlob,
@@ -580,16 +579,10 @@ export async function importLaddrFromJson(opts: ImportOptions): Promise<ImportRe
580579
for (const { record } of blogTranslations) {
581580
const artifacts = mediaArtifactsBySlug.get(record.slug) ?? [];
582581
if (artifacts.length > 0) {
583-
const blobs: Record<string, BlobObject> = {};
582+
const blobs: Record<string, BlobHandle> = {};
584583
for (const a of artifacts) {
585-
// BlobObject.write hashes the buffer into the git object DB.
586-
// Same `as unknown as string` cast as the avatar route — the
587-
// declared signature is too narrow; the underlying
588-
// git-client `$putBlob` accepts Buffer at runtime.
589-
blobs[a.filename] = await BlobObject.write(
590-
hologit,
591-
a.bytes as unknown as string,
592-
);
584+
// repo.writeBlob hashes the Buffer into the git object DB.
585+
blobs[a.filename] = await publicRepo.writeBlob(a.bytes);
593586
}
594587
await tx['blog-posts'].setAttachments(record, blobs);
595588
}

‎apps/api/src/routes/people.ts‎

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ import { computePersonPermissions, getCallerSession } from '../services/permissi
1616
import { buildTransactionOptions } from '../store/commit-meta.js';
1717
import type { UpdatePersonInput } from '../services/person.write.js';
1818
import { AVATAR_ALLOWED_MIME, processAvatar } from '../lib/avatar.js';
19-
import { BlobObject } from 'hologit';
2019
import type { Person } from '@cfp/shared/schemas';
2120
import { PersonSchema } from '@cfp/shared/schemas';
2221
import { StateApply } from '../store/state-apply.js';
@@ -404,7 +403,6 @@ export async function peopleRoutes(fastify: FastifyInstance): Promise<void> {
404403

405404
const newAvatarKey = `people/${person.slug}/avatar.jpg`;
406405
const stateApply = new StateApply();
407-
const hologit = fastify.publicRepo.hologitRepo;
408406

409407
let updatedPerson: Person = person;
410408
await fastify.store.transact(
@@ -418,17 +416,11 @@ export async function peopleRoutes(fastify: FastifyInstance): Promise<void> {
418416
}),
419417
async (tx) => {
420418
// Write the two attachment blobs into the gitsheets transaction
421-
// tree. BlobObject.write hashes the buffer into the git object DB
422-
// via `git hash-object -w`; the tx-level setAttachments then wires
423-
// the blob refs into the post-commit tree at the conventional path.
424-
//
425-
// BlobObject.write's TypeScript signature declares `content: string`
426-
// but the underlying `git-client` `$putBlob` spawns `git hash-object
427-
// --stdin -w` and pipes `content` to stdin, which accepts both
428-
// strings and Buffers at runtime. Cast to match the declared shape;
429-
// hologit's type would tighten upstream eventually.
430-
const originalBlob = await BlobObject.write(hologit, processed.original as unknown as string);
431-
const thumbnailBlob = await BlobObject.write(hologit, processed.thumbnail as unknown as string);
419+
// tree. repo.writeBlob hashes the buffer into the git object DB;
420+
// the tx-level setAttachments then wires the blob refs into the
421+
// post-commit tree at the conventional path.
422+
const originalBlob = await fastify.publicRepo.writeBlob(processed.original);
423+
const thumbnailBlob = await fastify.publicRepo.writeBlob(processed.thumbnail);
432424
await tx.public.people.setAttachments(person, {
433425
'avatar.jpg': originalBlob,
434426
'avatar-128.jpg': thumbnailBlob,

‎apps/api/src/store/public.ts‎

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,15 +33,74 @@ import type {
3333
import type { Project } from '@cfp/shared/schemas';
3434

3535
/**
36-
* Cast a Zod v4 schema to gitsheets' StandardSchemaV1.
36+
* Recursively drop `null` / `undefined`-valued keys from a record.
37+
*
38+
* gitsheets 2.x (Rust core) refuses to marshal `null` or `undefined` field
39+
* values — `serializeRecords`/`upsert` throw `cannot marshal JS value of type
40+
* Null/Undefined to a TOML value`. gitsheets 1.4.1 (`@iarna/toml`) silently
41+
* dropped such keys instead, so they were never written to disk: an absent
42+
* optional field is simply an absent TOML key (verified against the on-disk
43+
* `published` snapshot — no record carries a `null`-valued key).
44+
*
45+
* Our Zod schemas mark optional fields `.nullable().optional()` and the write
46+
* services normalize "cleared" fields to `?? null`. To keep the on-disk form
47+
* byte-identical to 1.4.1 (and to keep those `?? null` write paths working),
48+
* we strip null/undefined keys here, at the single write boundary, before the
49+
* record reaches the core marshaller. Nested tables (objects) are cleaned
50+
* recursively; arrays are passed through untouched (TOML has no null in
51+
* arrays, and our schemas never emit sparse arrays).
52+
*
53+
* `null` as an explicit "delete this field" signal only exists for
54+
* `Sheet.patch` (RFC 7396 merge-patch); we don't use `patch`, so stripping on
55+
* the full-record `upsert` path is unambiguous.
56+
*/
57+
function stripNullish(value: unknown): unknown {
58+
if (value === null || value === undefined) return undefined;
59+
if (Array.isArray(value)) return value;
60+
if (typeof value === 'object' && !(value instanceof Date)) {
61+
const out: Record<string, unknown> = {};
62+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
63+
const cleaned = stripNullish(v);
64+
if (cleaned !== undefined) out[k] = cleaned;
65+
}
66+
return out;
67+
}
68+
return value;
69+
}
70+
71+
/**
72+
* Cast a Zod v4 schema to gitsheets' StandardSchemaV1, wrapping its validator
73+
* so the validated record has null/undefined-valued keys stripped before it
74+
* reaches gitsheets' core marshaller.
75+
*
76+
* gitsheets runs the Standard Schema validator host-side and marshals the
77+
* validator's *output* — so stripping here (rather than at every upsert call
78+
* site) is the single, authoritative write boundary. See `stripNullish`.
3779
*
3880
* Zod v4 implements the Standard Schema interface at runtime, but TypeScript
3981
* cannot prove that Zod's Result type is assignable to gitsheets' narrow
4082
* StandardSchemaResult because of a structural mismatch in the FailureResult
4183
* shape. Both are correct at runtime; the cast is safe.
4284
*/
4385
function asValidator<T extends Record<string, unknown>>(schema: unknown): StandardSchemaV1<unknown, T> {
44-
return schema as StandardSchemaV1<unknown, T>;
86+
const inner = schema as StandardSchemaV1<unknown, T>;
87+
const innerValidate = inner['~standard'].validate;
88+
return {
89+
...inner,
90+
'~standard': {
91+
...inner['~standard'],
92+
validate: (value: unknown) => {
93+
const result = innerValidate(value);
94+
const strip = (
95+
r: Awaited<ReturnType<typeof innerValidate>>,
96+
): Awaited<ReturnType<typeof innerValidate>> =>
97+
r.issues === undefined
98+
? { value: stripNullish(r.value) as T }
99+
: r;
100+
return result instanceof Promise ? result.then(strip) : strip(result);
101+
},
102+
},
103+
};
45104
}
46105

47106
/** Typed validator map for openStore. */

‎apps/api/tests/store.test.ts‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,17 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
1111

1212
import { openStore } from 'gitsheets';
1313

14+
import { execFile } from 'node:child_process';
15+
import { promisify } from 'node:util';
16+
1417
import { PersonSchema, ProjectSchema } from '@cfp/shared/schemas';
1518
import { FilesystemPrivateStore } from '../src/store/private/filesystem.js';
1619
import { Store } from '../src/store/store.js';
20+
import { openPublicStore } from '../src/store/public.js';
1721
import { createTestRepo } from './helpers/test-repo.js';
22+
import { createFullDataRepo } from './helpers/test-full-repo.js';
23+
24+
const exec = promisify(execFile);
1825

1926
const now = '2026-05-16T00:00:00Z';
2027
const uuid = (n: number) => `01951a3c-0000-7000-8000-${String(n).padStart(12, '0')}`;
@@ -99,6 +106,61 @@ describe('public store (gitsheets)', () => {
99106
await cleanup();
100107
}
101108
});
109+
110+
it('drops null/undefined-valued keys before writing (gitsheets 2.x marshal contract)', async () => {
111+
// gitsheets 2.x (Rust core) throws when asked to marshal a null- or
112+
// undefined-valued field to TOML; 1.4.1 silently dropped such keys. Our
113+
// Zod schemas use `.nullable().optional()` and write services normalize
114+
// cleared fields to `?? null`, so openPublicStore's validator wrapper must
115+
// strip those keys — keeping the on-disk form byte-identical to 1.4.1
116+
// (an absent optional field is simply an absent TOML key). See
117+
// apps/api/src/store/public.ts → stripNullish / asValidator.
118+
const repo = await createFullDataRepo();
119+
try {
120+
const { store } = await openPublicStore(repo.path);
121+
122+
await store.transact(
123+
{ message: 'test: person with nullish fields', author: { name: 'test', email: 'test@cfp.test' } },
124+
async (tx) => {
125+
await tx.people.upsert(
126+
PersonSchema.parse({
127+
id: uuid(70),
128+
slug: 'nullish-person',
129+
fullName: 'Nullish Person',
130+
accountLevel: 'user',
131+
legacyId: 31618, // integer w/o underscore (2.x re-baseline)
132+
bio: null, // explicit null — must be dropped, not written
133+
avatarKey: null,
134+
deletedAt: null,
135+
createdAt: now,
136+
updatedAt: now,
137+
}),
138+
);
139+
},
140+
);
141+
142+
const { stdout: toml } = await exec(
143+
'git',
144+
['show', 'HEAD:people/nullish-person.toml'],
145+
{ cwd: repo.path },
146+
);
147+
148+
// Present fields survive.
149+
expect(toml).toContain('slug = "nullish-person"');
150+
expect(toml).toContain('fullName = "Nullish Person"');
151+
// Integer re-baseline: no underscore separator under the Rust core.
152+
expect(toml).toContain('legacyId = 31618');
153+
// Null-valued keys are absent from disk (never serialized as `null`).
154+
expect(toml).not.toMatch(/^bio\s*=/m);
155+
expect(toml).not.toMatch(/^avatarKey\s*=/m);
156+
expect(toml).not.toMatch(/^deletedAt\s*=/m);
157+
// No field is assigned a bare `null` value. (Substring 'null' on its own
158+
// would false-match the fixture's "Nullish Person" / "nullish-person".)
159+
expect(toml).not.toMatch(/=\s*null\b/);
160+
} finally {
161+
await repo.cleanup();
162+
}
163+
});
102164
});
103165

104166
// -------------------------------------------------------------------------

0 commit comments

Comments
 (0)