From e9a885ffa34f60b152e73a2d93e61167873a3ff7 Mon Sep 17 00:00:00 2001 From: Ed Date: Sat, 3 Oct 2026 10:46:10 +0300 Subject: [PATCH] fix: allow writes through shared table handles --- src/main/services/tableService.ts | 7 +++-- tests/main/tableWrites.test.ts | 50 +++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 3 deletions(-) create mode 100644 tests/main/tableWrites.test.ts diff --git a/src/main/services/tableService.ts b/src/main/services/tableService.ts index 2062c5c..90311cb 100644 --- a/src/main/services/tableService.ts +++ b/src/main/services/tableService.ts @@ -57,12 +57,13 @@ export function createTableService(): TableService { }; return { - async open(path, exclusive) { + async open(path, _exclusive) { // 'r+' so a later REPLACE can write; a table that is read-only on disk still opens to read, // and says so, because a browser that quietly refuses every edit is worse than one that // says the file cannot be written - let writable = exclusive; - const dbf = await open(path, exclusive ? 'r+' : 'r').catch(() => { + // Shared access is not read-only access. Both modes need a writable descriptor. + let writable = true; + const dbf = await open(path, 'r+').catch(() => { writable = false; return open(path, 'r'); }); diff --git a/tests/main/tableWrites.test.ts b/tests/main/tableWrites.test.ts new file mode 100644 index 0000000..2954d50 --- /dev/null +++ b/tests/main/tableWrites.test.ts @@ -0,0 +1,50 @@ +import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createTableService } from '@main/services/tableService'; + +let root: string; +let tables: ReturnType; +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'foxdev-table-write-')); + tables = createTableService(); +}); +afterEach(async () => { + await tables.closeAll(); + await rm(root, { recursive: true, force: true }); +}); + +describe('writable table handles', () => { + it.each([false, true])('persists bytes when exclusive=%s', async (exclusive) => { + const path = join(root, 'sample.dbf'); + // The service treats DBF contents as bytes; only header length is decoded. + const original = Buffer.alloc(40); + original.writeUInt16LE(32, 8); + original.writeUInt16LE(8, 10); + await writeFile(path, original); + const opened = await tables.open(path, exclusive); + await tables.write(opened.handle, 32, ' changed'); + await tables.close(opened.handle); + const expected = Buffer.from(original); + expected.write(' changed', 32, 'latin1'); + expect(await readFile(path)).toEqual(expected); + expect(opened.writable).toBe(true); + }); +}); + +// POSIX mode bits provide a real read-only file; Windows and root need another fixture. +it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)('falls back to read-only when writing is denied', async () => { + const path = join(root, 'readonly.dbf'); + const bytes = Buffer.alloc(32); + bytes.writeUInt16LE(32, 8); + await writeFile(path, bytes); + await chmod(path, 0o444); + try { + const opened = await tables.open(path, false); + expect(opened.writable).toBe(false); + expect(await tables.read(opened.handle, 0, 32)).toBe(bytes.toString('latin1')); + } finally { + await chmod(path, 0o644); + } +});