Skip to content

Commit 4ec86d2

Browse files
Merge pull request #243 from CodeForPhilly/chore/gitsheets-2.9
chore: gitsheets 2.9 — native renames, streamed attachments
2 parents 209f235 + 39d7465 commit 4ec86d2

14 files changed

Lines changed: 202 additions & 100 deletions

‎apps/api/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
"bcryptjs": "^3.0.3",
3737
"better-sqlite3": "^12.10.0",
3838
"fastify": "^5.8.5",
39-
"gitsheets": "^2.7.0",
39+
"gitsheets": "^2.9.0",
4040
"jose": "^6.2.3",
4141
"postmark": "^5.1.0",
4242
"samlify": "^2.13.0",

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
* (gitsheets auto-refresh) and after a hot reload (`store.public.refresh()`).
1212
*
1313
* The blob hash is the `ETag`, so a revalidation is answered with 304 without
14-
* reading the blob at all.
14+
* reading the blob at all; a 200 streams the blob with its `Content-Length`.
1515
*/
1616
import type { FastifyInstance, FastifyRequest } from 'fastify';
1717

@@ -120,7 +120,12 @@ export async function attachmentRoutes(fastify: FastifyInstance): Promise<void>
120120
return reply.code(304).send();
121121
}
122122

123-
return reply.type(inferContentType(key)).send(await blob.read());
123+
// Stream rather than buffer: gitsheets serves small blobs from one
124+
// in-core read and pipes larger ones from git.
125+
return reply
126+
.type(inferContentType(key))
127+
.header('Content-Length', blob.size)
128+
.send(blob.stream());
124129
},
125130
);
126131
}

‎apps/api/src/services/event-series.ts‎

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { StateApply } from '../store/state-apply.js';
1717
import type { DualStoreTx } from '../store/store.js';
1818
import { EventService, isUpcoming } from './event.js';
1919
import { MARKDOWN_UPSERT } from './event.write.js';
20-
import { relinkAttachments, restoreAttachments, takeAttachments } from './slug-rename.js';
20+
import { relinkAttachments, upsertMoved } from './slug-rename.js';
2121
import { computeEventSeriesPermissions, type CallerSession, type EventSeriesPermissions } from './permissions.js';
2222
import { renderMarkdown } from './serializers/common.js';
2323
import type { EventSummaryResponse, SeriesRef } from './serializers/event.js';
@@ -184,10 +184,7 @@ export class EventSeriesWriteService {
184184
updatedAt: new Date().toISOString(),
185185
});
186186
const stateApply = new StateApply();
187-
let attachments: Awaited<ReturnType<typeof takeAttachments>> = null;
188187
if (renamed) {
189-
attachments = await takeAttachments(tx.public['event-series'], existing);
190-
await tx.public['event-series'].delete(existing);
191188
const changedAt = new Date();
192189
const history = {
193190
id: uuidv7(),
@@ -201,8 +198,8 @@ export class EventSeriesWriteService {
201198
await tx.public['slug-history'].upsert(history);
202199
stateApply.renameEventSeriesSlug(existing.slug).upsertSlugHistory(history);
203200
}
204-
await tx.public['event-series'].upsert(updated, MARKDOWN_UPSERT);
205-
await restoreAttachments(tx.public['event-series'], updated, attachments);
201+
// Moves the record and its attachments when the slug changed.
202+
await upsertMoved(tx.public['event-series'], existing, updated, MARKDOWN_UPSERT);
206203
stateApply.upsertEventSeries(updated);
207204
return { series: updated, stateApply };
208205
}

‎apps/api/src/services/event.write.ts‎

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ import { StateApply } from '../store/state-apply.js';
3636
import type { DualStoreTx } from '../store/store.js';
3737
import { isRsvpOpen } from './permissions.js';
3838
import { cascadeRename } from './reference-cascade.js';
39-
import { relinkAttachments, restoreAttachments, takeAttachments } from './slug-rename.js';
39+
import { relinkAttachments, upsertMoved } from './slug-rename.js';
4040

4141
function nowIso(): string {
4242
return new Date().toISOString();
@@ -245,15 +245,12 @@ export class EventWriteService {
245245
});
246246

247247
const stateApply = new StateApply();
248-
let attachments: Awaited<ReturnType<typeof takeAttachments>> = null;
249248

250249
if (pathChanged) {
251-
// Delete the old file and move every RSVP file with it in this same
250+
// Move the event file (below) and every RSVP file with it in this same
252251
// commit (specs/api/events.md#patch-apieventsslug,
253252
// specs/behaviors/storage.md#sheet-layout). Slug history only for a
254253
// rename — the partition is invisible in URLs.
255-
attachments = await takeAttachments(tx.public.events, existing);
256-
await tx.public.events.delete(existing);
257254
if (slugChanged) {
258255
const changedAt = nowIso();
259256
const history = {
@@ -279,8 +276,8 @@ export class EventWriteService {
279276
});
280277
}
281278

282-
await tx.public.events.upsert(updated, MARKDOWN_UPSERT);
283-
await restoreAttachments(tx.public.events, updated, attachments);
279+
// Moves the record and its attachments when its path changed.
280+
await upsertMoved(tx.public.events, existing, updated, MARKDOWN_UPSERT);
284281
stateApply.upsertEvent(updated);
285282
return { event: updated, previous: existing, changes, stateApply };
286283
}

‎apps/api/src/services/person.write.ts‎

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ import type { SessionContext } from '../auth/middleware.js';
3535
import { applyTagsForEntity, type TagAssignmentInput, type TagNamespace } from './tag.write.js';
3636
import type { PrivateStore } from '../store/private/index.js';
3737
import { cascadeRename } from './reference-cascade.js';
38-
import { rekeyAttachment, restoreAttachments, takeAttachments } from './slug-rename.js';
38+
import { rekeyAttachment, upsertMoved } from './slug-rename.js';
3939

4040
function nowIso(): string {
4141
return new Date().toISOString();
@@ -117,10 +117,7 @@ export class PersonWriteService {
117117

118118
const stateApply = new StateApply();
119119

120-
let attachments: Awaited<ReturnType<typeof takeAttachments>> = null;
121120
if (newSlug !== existing.slug) {
122-
attachments = await takeAttachments(tx.public.people, existing);
123-
await tx.public.people.delete(existing);
124121
const history = {
125122
id: uuidv7(),
126123
entityType: 'person' as const,
@@ -135,8 +132,8 @@ export class PersonWriteService {
135132
stateApply.upsertSlugHistory(history);
136133
}
137134

138-
await tx.public.people.upsert(updated);
139-
await restoreAttachments(tx.public.people, updated, attachments);
135+
// Moves the record and its attachments when the slug changed.
136+
await upsertMoved(tx.public.people, existing, updated);
140137
stateApply.upsertPerson(updated);
141138

142139
if (newSlug !== existing.slug) {

‎apps/api/src/services/project-buzz.write.ts‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { ApiNotFoundError, ApiValidationError, ConflictError } from '../lib/erro
1313
import { ensureUniqueSlug, isValidBuzzSlug, slugify } from '../lib/slug.js';
1414
import { requireAuth } from '../auth/require.js';
1515
import type { SessionContext } from '../auth/middleware.js';
16+
import { rekeyAttachment, upsertMoved } from './slug-rename.js';
1617

1718
function nowIso(): string {
1819
return new Date().toISOString();
@@ -166,20 +167,19 @@ export class ProjectBuzzWriteService {
166167
summary: input.summary === undefined ? (existing.summary ?? null) : input.summary,
167168
imageKey:
168169
input.imageUpload === undefined
169-
? (existing.imageKey ?? null)
170+
? (rekeyAttachment(existing.imageKey, `project-buzz/${project.slug}`, existing.slug, newSlug) ??
171+
null)
170172
: (input.imageUpload?.key ?? null),
171173
slug: newSlug,
172174
updatedAt: nowIso(),
173175
});
174176

175177
const stateApply = new StateApply();
176178

177-
if (slugChanged) {
178-
await tx.public['project-buzz'].delete(existing);
179-
stateApply.removeProjectBuzz(existing);
180-
}
179+
if (slugChanged) stateApply.removeProjectBuzz(existing);
181180

182-
await tx.public['project-buzz'].upsert(updated);
181+
// Moves the record and its image when the slug changed.
182+
await upsertMoved(tx.public['project-buzz'], existing, updated);
183183
stateApply.upsertProjectBuzz(updated);
184184

185185
return { buzz: updated, stateApply };

‎apps/api/src/services/project.write.ts‎

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import { requireAuth } from '../auth/require.js';
2727
import type { SessionContext } from '../auth/middleware.js';
2828
import { applyTagsForEntity, type TagAssignmentInput } from './tag.write.js';
2929
import { cascadeRename } from './reference-cascade.js';
30-
import { rekeyAttachment, restoreAttachments, takeAttachments } from './slug-rename.js';
30+
import { rekeyAttachment, upsertMoved } from './slug-rename.js';
3131

3232
export interface CreateProjectInput {
3333
readonly title: string;
@@ -279,12 +279,9 @@ export class ProjectWriteService {
279279

280280
const stateApply = new StateApply();
281281

282-
let attachments: Awaited<ReturnType<typeof takeAttachments>> = null;
283282
if (slugRename) {
284283
// Delete the old path first, then upsert at the new path, carrying
285284
// the record's attachments across (delete removes them).
286-
attachments = await takeAttachments(tx.public.projects, existing);
287-
await tx.public.projects.delete(existing);
288285
// SlugHistory record
289286
const history = {
290287
id: uuidv7(),
@@ -300,8 +297,8 @@ export class ProjectWriteService {
300297
stateApply.upsertSlugHistory(history);
301298
}
302299

303-
await tx.public.projects.upsert(updated);
304-
await restoreAttachments(tx.public.projects, updated, attachments);
300+
// Moves the record and its attachments when the slug changed.
301+
await upsertMoved(tx.public.projects, existing, updated);
305302
stateApply.upsertProject(updated);
306303

307304
if (slugRename) {

‎apps/api/src/services/reference-cascade.ts‎

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import type { InMemoryState } from '../store/memory/state.js';
2727
import { PUBLIC_SHEET_SCHEMAS } from '../store/public.js';
2828
import type { StateApply } from '../store/state-apply.js';
2929
import type { DualStoreTx } from '../store/store.js';
30-
import { restoreAttachments, takeAttachments } from './slug-rename.js';
30+
import { upsertMoved } from './slug-rename.js';
3131

3232
/** How the cascade reads and re-indexes one referencing sheet's records. */
3333
interface ReferencingSheet<T> {
@@ -173,10 +173,8 @@ export async function cascadeRename(
173173
if (oldPath === newPath && Object.keys(also).every((k) => record[k] === moved[k])) continue;
174174
const updated = schema.parse(rekeyMovedRecord(moved, ref.sheet, oldPath, newPath));
175175

176-
const attachments = await takeAttachments(txSheet, record);
177-
await txSheet.delete(record);
178-
await txSheet.upsert(updated);
179-
await restoreAttachments(txSheet, updated, attachments);
176+
// Moves the record and its attachments to the new path.
177+
await upsertMoved(txSheet, record, updated);
180178

181179
adapter.remove(stateApply, record);
182180
adapter.upsert(stateApply, updated);

‎apps/api/src/services/slug-rename.ts‎

Lines changed: 25 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,35 @@
11
/**
2-
* Keep a record's attachments across a slug rename.
2+
* Rename helpers — specs/behaviors/slug-handles.md: a rename changes the
3+
* handle and nothing else about the entity, so the record's attachments
4+
* move with it and any key or link pointing under its old path is
5+
* rewritten.
36
*
4-
* A rename deletes the record at its old path and upserts it at the new one.
5-
* gitsheets' delete also removes the record's attachment folder, so without
6-
* this the attachments (avatars, featured images) are lost and the on-record
7-
* key points at a path that no longer exists. specs/behaviors/slug-handles.md:
8-
* a rename changes the handle and nothing else about the entity.
9-
*
10-
* Interim until gitsheets can move a record together with its attachments
11-
* (plans/slug-rename-attachments.md).
7+
* The move itself is gitsheets' rename-on-upsert (≥ 2.8): upserting a
8+
* record annotated with its *current* path, when its fields now render a
9+
* different path, moves the record file and its attachment folder in one
10+
* step.
1211
*/
13-
import type { BlobHandle } from 'gitsheets';
14-
15-
interface AttachmentSheet<T> {
16-
getAttachments(record: T | string): Promise<Record<string, BlobHandle> | null>;
17-
setAttachments(record: T | string, attachments: Record<string, BlobHandle>): Promise<void>;
18-
}
12+
import { RECORD_PATH_KEY } from 'gitsheets';
1913

20-
/** Read the record's attachments before its old path is deleted. */
21-
export async function takeAttachments<T>(
22-
sheet: AttachmentSheet<T>,
23-
record: T,
24-
): Promise<Record<string, BlobHandle> | null> {
25-
const attachments = await sheet.getAttachments(record);
26-
return attachments && Object.keys(attachments).length > 0 ? attachments : null;
14+
interface MovableSheet<T, O> {
15+
pathForRecord(record: T): Promise<string>;
16+
upsert(record: T, opts?: O): Promise<unknown>;
2717
}
2818

29-
/** Re-attach to the record at its new path (blobs are reused by hash). */
30-
export async function restoreAttachments<T>(
31-
sheet: AttachmentSheet<T>,
32-
record: T,
33-
attachments: Record<string, BlobHandle> | null,
19+
/**
20+
* Write `updated` in place of `existing`, moving the record (and its
21+
* attachments) when its path changed. The old path is rendered from
22+
* `existing` rather than read from its path annotation, which a record
23+
* re-parsed through Zod no longer carries.
24+
*/
25+
export async function upsertMoved<T extends object, O = never>(
26+
sheet: MovableSheet<T, O>,
27+
existing: T,
28+
updated: T,
29+
opts?: O,
3430
): Promise<void> {
35-
if (attachments) await sheet.setAttachments(record, attachments);
31+
const oldPath = await sheet.pathForRecord(existing);
32+
await sheet.upsert(Object.assign({ ...updated }, { [RECORD_PATH_KEY]: oldPath }) as T, opts);
3633
}
3734

3835
/**

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,4 +186,17 @@ describe('GET /api/attachments/*', () => {
186186
const res = await app.inject({ method: 'GET', url: '/api/attachments/not-a-sheet/x/y.png' });
187187
expect(res.statusCode).toBe(404);
188188
});
189+
190+
it('streams a blob larger than 1 MiB intact, with Content-Length', async () => {
191+
const big = Buffer.alloc(3 * 1024 * 1024 + 17);
192+
for (let i = 0; i < big.length; i++) big[i] = (i * 31 + 7) & 0xff;
193+
await seedRawBlob(dataRepo.path, 'people/big/scan.pdf', big, 'seed a large attachment');
194+
app = await bootApp();
195+
196+
const res = await app.inject({ method: 'GET', url: '/api/attachments/people/big/scan.pdf' });
197+
expect(res.statusCode).toBe(200);
198+
expect(res.headers['content-length']).toBe(String(big.length));
199+
expect(res.rawPayload.equals(big)).toBe(true);
200+
});
189201
});
202+

0 commit comments

Comments
 (0)