fix(acadenice): use camelCase row keys in sync-block repo — Patch 027

The DatabaseModule registers a global CamelCasePlugin which converts
every column name (including SELECT aliases) from snake_case to
camelCase at runtime. The sync-block repo declared sql<{...}> result
types in snake_case (workspace_id, created_at, etc.) and accessed those
keys in mapRow / findUsages. At runtime kysely returned camelCase keys
so every property read was undefined, causing 'Invalid time value' on
new Date(undefined).toISOString().

Same pattern is likely present in graph.service.ts and backlinks
services — Patch 028 will sweep those.

Verified via curl: POST /api/acadenice/sync-blocks now returns 201 with
the full DTO.

Patch 027.
This commit is contained in:
Corentin JOGUET 2026-05-08 12:53:29 +02:00
parent 7fba3c0452
commit 8c3d55024b

View file

@ -24,11 +24,11 @@ export class SyncBlockRepo {
): Promise<SyncBlockResponseDto> { ): Promise<SyncBlockResponseDto> {
const result = await sql<{ const result = await sql<{
id: string; id: string;
workspace_id: string; workspaceId: string;
content: Record<string, unknown>; content: Record<string, unknown>;
created_by: string; createdBy: string;
created_at: Date; createdAt: Date;
updated_at: Date; updatedAt: Date;
}>` }>`
INSERT INTO acadenice_sync_block (workspace_id, content, created_by) INSERT INTO acadenice_sync_block (workspace_id, content, created_by)
VALUES (${workspaceId}, ${JSON.stringify(content)}::jsonb, ${createdBy}) VALUES (${workspaceId}, ${JSON.stringify(content)}::jsonb, ${createdBy})
@ -44,11 +44,11 @@ export class SyncBlockRepo {
): Promise<SyncBlockResponseDto | null> { ): Promise<SyncBlockResponseDto | null> {
const result = await sql<{ const result = await sql<{
id: string; id: string;
workspace_id: string; workspaceId: string;
content: Record<string, unknown>; content: Record<string, unknown>;
created_by: string; createdBy: string;
created_at: Date; createdAt: Date;
updated_at: Date; updatedAt: Date;
}>` }>`
SELECT id, workspace_id, content, created_by, created_at, updated_at SELECT id, workspace_id, content, created_by, created_at, updated_at
FROM acadenice_sync_block FROM acadenice_sync_block
@ -67,11 +67,11 @@ export class SyncBlockRepo {
): Promise<SyncBlockResponseDto | null> { ): Promise<SyncBlockResponseDto | null> {
const result = await sql<{ const result = await sql<{
id: string; id: string;
workspace_id: string; workspaceId: string;
content: Record<string, unknown>; content: Record<string, unknown>;
created_by: string; createdBy: string;
created_at: Date; createdAt: Date;
updated_at: Date; updatedAt: Date;
}>` }>`
UPDATE acadenice_sync_block UPDATE acadenice_sync_block
SET content = ${JSON.stringify(content)}::jsonb, updated_at = NOW() SET content = ${JSON.stringify(content)}::jsonb, updated_at = NOW()
@ -101,7 +101,7 @@ export class SyncBlockRepo {
id: string, id: string,
workspaceId: string, workspaceId: string,
): Promise<Buffer | null> { ): Promise<Buffer | null> {
const result = await sql<{ yjs_state: Buffer | null }>` const result = await sql<{ yjsState: Buffer | null }>`
SELECT yjs_state SELECT yjs_state
FROM acadenice_sync_block FROM acadenice_sync_block
WHERE id = ${id} WHERE id = ${id}
@ -109,7 +109,7 @@ export class SyncBlockRepo {
`.execute(this.db); `.execute(this.db);
if (result.rows.length === 0) return null; if (result.rows.length === 0) return null;
return result.rows[0].yjs_state ?? null; return result.rows[0].yjsState ?? null;
} }
async delete(id: string, workspaceId: string): Promise<boolean> { async delete(id: string, workspaceId: string): Promise<boolean> {
@ -137,12 +137,15 @@ export class SyncBlockRepo {
// Pages store their content as JSONB. We search for any object in the // Pages store their content as JSONB. We search for any object in the
// content tree where type='syncBlock' and attrs->>'masterId' = blockId. // content tree where type='syncBlock' and attrs->>'masterId' = blockId.
// Using jsonb_path_exists for recursive search. // Using jsonb_path_exists for recursive search.
// CamelCasePlugin (in DatabaseModule) converts snake_case columns to
// camelCase at runtime, including SELECT aliases. So even though the SQL
// says "page_id" the row property is "pageId".
const result = await sql<{ const result = await sql<{
page_id: string; pageId: string;
title: string | null; title: string | null;
slug_id: string; slugId: string;
space_id: string; spaceId: string;
workspace_id: string; workspaceId: string;
}>` }>`
SELECT DISTINCT p.id AS page_id, p.title, p.slug_id, p.space_id, s.workspace_id SELECT DISTINCT p.id AS page_id, p.title, p.slug_id, p.space_id, s.workspace_id
FROM pages p FROM pages p
@ -158,11 +161,11 @@ export class SyncBlockRepo {
`.execute(this.db); `.execute(this.db);
return result.rows.map((r) => ({ return result.rows.map((r) => ({
pageId: r.page_id, pageId: r.pageId,
pageTitle: r.title, pageTitle: r.title,
slugId: r.slug_id, slugId: r.slugId,
spaceId: r.space_id, spaceId: r.spaceId,
workspaceId: r.workspace_id, workspaceId: r.workspaceId,
})); }));
} }
@ -178,19 +181,19 @@ export class SyncBlockRepo {
private mapRow(row: { private mapRow(row: {
id: string; id: string;
workspace_id: string; workspaceId: string;
content: Record<string, unknown>; content: Record<string, unknown>;
created_by: string; createdBy: string;
created_at: Date | string; createdAt: Date | string;
updated_at: Date | string; updatedAt: Date | string;
}): SyncBlockResponseDto { }): SyncBlockResponseDto {
return { return {
id: row.id, id: row.id,
workspaceId: row.workspace_id, workspaceId: row.workspaceId,
content: row.content, content: row.content,
createdBy: row.created_by, createdBy: row.createdBy,
createdAt: new Date(row.created_at).toISOString(), createdAt: new Date(row.createdAt).toISOString(),
updatedAt: new Date(row.updated_at).toISOString(), updatedAt: new Date(row.updatedAt).toISOString(),
}; };
} }
} }