Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.10.0",
"fastify": "^5.8.5",
"gitsheets": "^1.4.1",
"gitsheets": "^2.2.0",
"jose": "^6.2.3",
"resend": "^6.12.4",
"samlify": "^2.13.0",
Expand Down
21 changes: 7 additions & 14 deletions apps/api/scripts/import-laddr/importer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ import {
type TranslateCtx,
type Warnings,
} from './translators.js';
import { BlobObject } from 'hologit';
import type { BlobHandle } from 'gitsheets';

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

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

log(`[import] clear + upsert tags (${tags.length})`);
await tx.tags.clear();
Expand All @@ -513,8 +512,8 @@ export async function importLaddrFromJson(opts: ImportOptions): Promise<ImportRe
if (avatar) {
// Mirror POST /api/people/:slug/avatar: store original + 128 thumb
// as attachments and point avatarKey at the conventional path.
const originalBlob = await BlobObject.write(hologit, avatar.original as unknown as string);
const thumbnailBlob = await BlobObject.write(hologit, avatar.thumbnail as unknown as string);
const originalBlob = await publicRepo.writeBlob(avatar.original);
const thumbnailBlob = await publicRepo.writeBlob(avatar.thumbnail);
await tx.people.setAttachments(p, {
'avatar.jpg': originalBlob,
'avatar-128.jpg': thumbnailBlob,
Expand Down Expand Up @@ -580,16 +579,10 @@ export async function importLaddrFromJson(opts: ImportOptions): Promise<ImportRe
for (const { record } of blogTranslations) {
const artifacts = mediaArtifactsBySlug.get(record.slug) ?? [];
if (artifacts.length > 0) {
const blobs: Record<string, BlobObject> = {};
const blobs: Record<string, BlobHandle> = {};
for (const a of artifacts) {
// BlobObject.write hashes the buffer into the git object DB.
// Same `as unknown as string` cast as the avatar route — the
// declared signature is too narrow; the underlying
// git-client `$putBlob` accepts Buffer at runtime.
blobs[a.filename] = await BlobObject.write(
hologit,
a.bytes as unknown as string,
);
// repo.writeBlob hashes the Buffer into the git object DB.
blobs[a.filename] = await publicRepo.writeBlob(a.bytes);
}
await tx['blog-posts'].setAttachments(record, blobs);
}
Expand Down
18 changes: 5 additions & 13 deletions apps/api/src/routes/people.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import { computePersonPermissions, getCallerSession } from '../services/permissi
import { buildTransactionOptions } from '../store/commit-meta.js';
import type { UpdatePersonInput } from '../services/person.write.js';
import { AVATAR_ALLOWED_MIME, processAvatar } from '../lib/avatar.js';
import { BlobObject } from 'hologit';
import type { Person } from '@cfp/shared/schemas';
import { PersonSchema } from '@cfp/shared/schemas';
import { StateApply } from '../store/state-apply.js';
Expand Down Expand Up @@ -404,7 +403,6 @@ export async function peopleRoutes(fastify: FastifyInstance): Promise<void> {

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

let updatedPerson: Person = person;
await fastify.store.transact(
Expand All @@ -418,17 +416,11 @@ export async function peopleRoutes(fastify: FastifyInstance): Promise<void> {
}),
async (tx) => {
// Write the two attachment blobs into the gitsheets transaction
// tree. BlobObject.write hashes the buffer into the git object DB
// via `git hash-object -w`; the tx-level setAttachments then wires
// the blob refs into the post-commit tree at the conventional path.
//
// BlobObject.write's TypeScript signature declares `content: string`
// but the underlying `git-client` `$putBlob` spawns `git hash-object
// --stdin -w` and pipes `content` to stdin, which accepts both
// strings and Buffers at runtime. Cast to match the declared shape;
// hologit's type would tighten upstream eventually.
const originalBlob = await BlobObject.write(hologit, processed.original as unknown as string);
const thumbnailBlob = await BlobObject.write(hologit, processed.thumbnail as unknown as string);
// tree. repo.writeBlob hashes the buffer into the git object DB;
// the tx-level setAttachments then wires the blob refs into the
// post-commit tree at the conventional path.
const originalBlob = await fastify.publicRepo.writeBlob(processed.original);
const thumbnailBlob = await fastify.publicRepo.writeBlob(processed.thumbnail);
await tx.public.people.setAttachments(person, {
'avatar.jpg': originalBlob,
'avatar-128.jpg': thumbnailBlob,
Expand Down
63 changes: 61 additions & 2 deletions apps/api/src/store/public.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,74 @@ import type {
import type { Project } from '@cfp/shared/schemas';

/**
* Cast a Zod v4 schema to gitsheets' StandardSchemaV1.
* Recursively drop `null` / `undefined`-valued keys from a record.
*
* gitsheets 2.x (Rust core) refuses to marshal `null` or `undefined` field
* values — `serializeRecords`/`upsert` throw `cannot marshal JS value of type
* Null/Undefined to a TOML value`. gitsheets 1.4.1 (`@iarna/toml`) silently
* dropped such keys instead, so they were never written to disk: an absent
* optional field is simply an absent TOML key (verified against the on-disk
* `published` snapshot — no record carries a `null`-valued key).
*
* Our Zod schemas mark optional fields `.nullable().optional()` and the write
* services normalize "cleared" fields to `?? null`. To keep the on-disk form
* byte-identical to 1.4.1 (and to keep those `?? null` write paths working),
* we strip null/undefined keys here, at the single write boundary, before the
* record reaches the core marshaller. Nested tables (objects) are cleaned
* recursively; arrays are passed through untouched (TOML has no null in
* arrays, and our schemas never emit sparse arrays).
*
* `null` as an explicit "delete this field" signal only exists for
* `Sheet.patch` (RFC 7396 merge-patch); we don't use `patch`, so stripping on
* the full-record `upsert` path is unambiguous.
*/
function stripNullish(value: unknown): unknown {
if (value === null || value === undefined) return undefined;
if (Array.isArray(value)) return value;
if (typeof value === 'object' && !(value instanceof Date)) {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
const cleaned = stripNullish(v);
if (cleaned !== undefined) out[k] = cleaned;
}
return out;
}
return value;
}

/**
* Cast a Zod v4 schema to gitsheets' StandardSchemaV1, wrapping its validator
* so the validated record has null/undefined-valued keys stripped before it
* reaches gitsheets' core marshaller.
*
* gitsheets runs the Standard Schema validator host-side and marshals the
* validator's *output* — so stripping here (rather than at every upsert call
* site) is the single, authoritative write boundary. See `stripNullish`.
*
* Zod v4 implements the Standard Schema interface at runtime, but TypeScript
* cannot prove that Zod's Result type is assignable to gitsheets' narrow
* StandardSchemaResult because of a structural mismatch in the FailureResult
* shape. Both are correct at runtime; the cast is safe.
*/
function asValidator<T extends Record<string, unknown>>(schema: unknown): StandardSchemaV1<unknown, T> {
return schema as StandardSchemaV1<unknown, T>;
const inner = schema as StandardSchemaV1<unknown, T>;
const innerValidate = inner['~standard'].validate;
return {
...inner,
'~standard': {
...inner['~standard'],
validate: (value: unknown) => {
const result = innerValidate(value);
const strip = (
r: Awaited<ReturnType<typeof innerValidate>>,
): Awaited<ReturnType<typeof innerValidate>> =>
r.issues === undefined
? { value: stripNullish(r.value) as T }
: r;
return result instanceof Promise ? result.then(strip) : strip(result);
},
},
};
}

/** Typed validator map for openStore. */
Expand Down
62 changes: 62 additions & 0 deletions apps/api/tests/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,17 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';

import { openStore } from 'gitsheets';

import { execFile } from 'node:child_process';
import { promisify } from 'node:util';

import { PersonSchema, ProjectSchema } from '@cfp/shared/schemas';
import { FilesystemPrivateStore } from '../src/store/private/filesystem.js';
import { Store } from '../src/store/store.js';
import { openPublicStore } from '../src/store/public.js';
import { createTestRepo } from './helpers/test-repo.js';
import { createFullDataRepo } from './helpers/test-full-repo.js';

const exec = promisify(execFile);

const now = '2026-05-16T00:00:00Z';
const uuid = (n: number) => `01951a3c-0000-7000-8000-${String(n).padStart(12, '0')}`;
Expand Down Expand Up @@ -99,6 +106,61 @@ describe('public store (gitsheets)', () => {
await cleanup();
}
});

it('drops null/undefined-valued keys before writing (gitsheets 2.x marshal contract)', async () => {
// gitsheets 2.x (Rust core) throws when asked to marshal a null- or
// undefined-valued field to TOML; 1.4.1 silently dropped such keys. Our
// Zod schemas use `.nullable().optional()` and write services normalize
// cleared fields to `?? null`, so openPublicStore's validator wrapper must
// strip those keys — keeping the on-disk form byte-identical to 1.4.1
// (an absent optional field is simply an absent TOML key). See
// apps/api/src/store/public.ts → stripNullish / asValidator.
const repo = await createFullDataRepo();
try {
const { store } = await openPublicStore(repo.path);

await store.transact(
{ message: 'test: person with nullish fields', author: { name: 'test', email: 'test@cfp.test' } },
async (tx) => {
await tx.people.upsert(
PersonSchema.parse({
id: uuid(70),
slug: 'nullish-person',
fullName: 'Nullish Person',
accountLevel: 'user',
legacyId: 31618, // integer w/o underscore (2.x re-baseline)
bio: null, // explicit null — must be dropped, not written
avatarKey: null,
deletedAt: null,
createdAt: now,
updatedAt: now,
}),
);
},
);

const { stdout: toml } = await exec(
'git',
['show', 'HEAD:people/nullish-person.toml'],
{ cwd: repo.path },
);

// Present fields survive.
expect(toml).toContain('slug = "nullish-person"');
expect(toml).toContain('fullName = "Nullish Person"');
// Integer re-baseline: no underscore separator under the Rust core.
expect(toml).toContain('legacyId = 31618');
// Null-valued keys are absent from disk (never serialized as `null`).
expect(toml).not.toMatch(/^bio\s*=/m);
expect(toml).not.toMatch(/^avatarKey\s*=/m);
expect(toml).not.toMatch(/^deletedAt\s*=/m);
// No field is assigned a bare `null` value. (Substring 'null' on its own
// would false-match the fixture's "Nullish Person" / "nullish-person".)
expect(toml).not.toMatch(/=\s*null\b/);
} finally {
await repo.cleanup();
}
});
});

// -------------------------------------------------------------------------
Expand Down
Loading