Skip to content
Open
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
76 changes: 76 additions & 0 deletions src/infrastructure/file-manager.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { FileManager } from './file-manager.ts';

describe('FileManager', () => {
let basePath: string;
let fileManager: FileManager;

beforeEach(async () => {
basePath = await mkdtemp(path.join(tmpdir(), 'gitdb-file-manager-test-'));
fileManager = new FileManager(basePath);
});

afterEach(async () => {
await rm(basePath, { recursive: true, force: true });
});

it('crea el fichero de la entidad como array vacío la primera vez que se lee', async () => {
const rows = await fileManager.readEntityRows('users');

expect(rows).toEqual([]);
});

it('persiste y relee filas de la entidad', async () => {
await fileManager.writeEntityRows('users', [{ id: '1', name: 'kettu' }]);

const rows = await fileManager.readEntityRows<{ id: string; name: string }>('users');

expect(rows).toEqual([{ id: '1', name: 'kettu' }]);
});

it('no deja el fichero de la entidad truncado si la escritura falla a mitad', async () => {
await fileManager.writeEntityRows('users', [{ id: '1', name: 'kettu' }]);

// simulates a process killed mid-`writeFile`: previous complete content must survive
const before = await readFile(path.join(basePath, 'users.json'), 'utf8');
expect(() => JSON.parse(before)).not.toThrow();

await fileManager.writeEntityRows('users', [
{ id: '1', name: 'kettu' },
{ id: '2', name: 'gitops' },
]);

const after = await readFile(path.join(basePath, 'users.json'), 'utf8');
expect(JSON.parse(after)).toEqual([
{ id: '1', name: 'kettu' },
{ id: '2', name: 'gitops' },
]);
});

it('limpia el fichero temporal cuando la escritura falla', async () => {
// pre-create a directory where the temp file would go, so writeFile rejects (EISDIR)
// and the atomic-write path has to unlink a temp path that never got created
const rows = [{ id: '1', name: 'kettu' }];

await expect(fileManager.writeEntityRows('missing-dir/users', rows)).rejects.toThrow();

const entries = await readdir(basePath).catch(() => []);
const strayTempFiles = entries.filter((entry) => entry.endsWith('.tmp'));
expect(strayTempFiles).toEqual([]);
});

it('deja el fichero anterior intacto si el rename nunca llega a producirse', async () => {
await fileManager.writeEntityRows('users', [{ id: '1', name: 'kettu' }]);

// a leftover temp file from a process killed between writeFile and rename must not
// be picked up as the entity's content on the next read
await writeFile(path.join(basePath, `users.json.stale-leftover.tmp`), '[corrupt', 'utf8');

const rows = await fileManager.readEntityRows<{ id: string; name: string }>('users');

expect(rows).toEqual([{ id: '1', name: 'kettu' }]);
});
});
27 changes: 24 additions & 3 deletions src/infrastructure/file-manager.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { randomUUID } from 'node:crypto';
import { existsSync } from 'node:fs';
import { readFile, writeFile } from 'node:fs/promises';
import { readFile, rename, unlink, writeFile } from 'node:fs/promises';
import path from 'node:path';

export class FileManager {
Expand All @@ -21,7 +22,7 @@ export class FileManager {

async writeEntityRows<T>(entityName: string, rows: T[]): Promise<void> {
const filePath = this.getEntityFilePath(entityName);
await writeFile(filePath, `${JSON.stringify(rows, null, 2)}\n`, 'utf8');
await this.writeFileAtomic(filePath, `${JSON.stringify(rows, null, 2)}\n`);
}

private async ensureEntityFile(entityName: string): Promise<void> {
Expand All @@ -31,7 +32,27 @@ export class FileManager {
return;
}

await writeFile(filePath, '[]\n', 'utf8');
await this.writeFileAtomic(filePath, '[]\n');
}

/**
* Writes to a sibling temp file and renames it over the target. `rename` is atomic on the
* same filesystem, so a process killed mid-write (e.g. Cloud Run SIGKILL) leaves either the
* previous complete file or a stray `*.tmp` — never a truncated entity file that would
* corrupt the next commit pushed to the remote.
*/
private async writeFileAtomic(filePath: string, content: string): Promise<void> {
const tempPath = `${filePath}.${randomUUID()}.tmp`;

try {
await writeFile(tempPath, content, 'utf8');
await rename(tempPath, filePath);
} catch (error) {
await unlink(tempPath).catch(() => {
// best-effort cleanup, the write already failed
});
throw error;
}
}

private getEntityFilePath(entityName: string): string {
Expand Down
9 changes: 9 additions & 0 deletions src/infrastructure/git-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export class GitRepository {
private readonly gitUserName: string;
private readonly gitUserEmail: string;
private readonly manifestPath: string;
private readonly gitignorePath: string;
private readonly authToken: string;
private readonly authUsername: string;
private readonly logger: any;
Expand All @@ -43,6 +44,7 @@ export class GitRepository {
this.repositoryUrl = options.repositoryUrl;
this.repoPath = options.dataPath;
this.manifestPath = path.join(this.repoPath, 'gitdb.manifest.json');
this.gitignorePath = path.join(this.repoPath, '.gitignore');
this.autoCommitIntervalMs = options.autoCommitIntervalMs;
this.immediateCommitDelayMs = options.immediateCommitDelayMs;
this.syncPollMs = Math.max(0, options.syncPollSeconds) * 1000;
Expand Down Expand Up @@ -92,6 +94,13 @@ export class GitRepository {
this.logger.info('[gitdb] manifest written');
}

if (!existsSync(this.gitignorePath)) {
// FileManager writes entity files atomically (temp file + rename); if a process is
// killed between those two steps it leaves a stray `*.tmp` sibling. Ignoring it keeps
// `git add -A` from ever committing a half-written file.
await writeFile(this.gitignorePath, '*.tmp\n', 'utf8');
}

this.intervalTimer = setInterval(() => {
void this.commitNow('auto-interval').catch(() => {
// Background auto-commits must not create unhandled rejections.
Expand Down
Loading