fix(session-persistence): create SQLite databases owner-only

This commit is contained in:
Yichen Jiang
2026-07-17 10:15:19 +08:00
parent 4139e093dd
commit 96975f3840
4 changed files with 66 additions and 9 deletions

View File

@@ -603,8 +603,8 @@ Requires: `sessions`
export interface Config {
/**
* Filesystem path to the SQLite database file. The special value `:memory:`
* opens an in-process database (tests); a file path is created (with parent
* dirs) on construction.
* opens an in-process database (tests). Missing directories and the database
* are created with owner-only permissions; existing path modes are preserved.
*/
path: string
/**
@@ -627,7 +627,7 @@ export interface Config {
export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
```
Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:38`](../packages/session-persistence/session-persistence-sqlite/src/index.ts)
Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:48`](../packages/session-persistence/session-persistence-sqlite/src/index.ts)
## `@deepseek-ai/dsh-session-query`

View File

@@ -8,7 +8,7 @@ A SQLite durable session-persistence backend — a second `SessionPersistence` i
Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, type, time, data, source_event_seqs, surface_op)``data` is the event payload as JSON text, so the row shape is the event verbatim (including `assistant/chunk`, keeping `seq` contiguous). The two `TEXT` columns `source_event_seqs` and `surface_op` are nullable; they store the event's optional surface-metadata fields (see [session surface](../../../docs/rfc/implemented/architecture/2026-06-18-session-surface.md)). Out-of-log metadata (`SessionHeader`) lives in a `sessions` row. A `sessions` row is written only by the first `append` — its existence is the lazy-materialization signal (`list` reports exactly the sessions that have a row), so no separate column is needed.
The repository's Node range supports unflagged `node:sqlite`. The database enables foreign keys and uses the configured journal mode (`wal` by default; use a rollback mode where WAL shared-memory files are unsuitable). `PRAGMA user_version` stores the table-layout version; databases with any other version are rejected because this unreleased format has no migrations.
The repository's Node range supports unflagged `node:sqlite`. The database enables foreign keys and uses the configured journal mode (`wal` by default; use a rollback mode where WAL shared-memory files are unsuitable). On filesystems with POSIX modes, missing directories are created as `0700` and a missing database is exclusively created as `0600` before SQLite opens it, causing new WAL sidecars to inherit owner-only access. Existing directories, database files, and sidecars keep their modes; ordinary filesystem access errors still fail initialization. `PRAGMA user_version` stores the table-layout version; databases with any other version are rejected because this unreleased format has no migrations.
## Contract semantics over rows

View File

@@ -8,7 +8,7 @@
import { Context } from 'cordis'
import z from 'schemastery'
import { DatabaseSync } from 'node:sqlite'
import { mkdir } from 'node:fs/promises'
import { mkdir, open } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import {
SessionPersistence, PersistenceCoordinator,
@@ -34,12 +34,22 @@ function surfaceBindings(event: SessionEvent): [string | null, string | null] {
]
}
/** Create a missing database owner-only while preserving an existing file's mode. */
async function createDatabaseFile(path: string): Promise<void> {
try {
const handle = await open(path, 'wx', 0o600)
await handle.close()
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
}
}
/** Plugin configuration. */
export interface Config {
/**
* Filesystem path to the SQLite database file. The special value `:memory:`
* opens an in-process database (tests); a file path is created (with parent
* dirs) on construction.
* opens an in-process database (tests). Missing directories and the database
* are created with owner-only permissions; existing path modes are preserved.
*/
path: string
/**
@@ -87,6 +97,7 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
if (path !== ':memory:') {
const abs = resolve(path)
await mkdir(dirname(abs), { recursive: true, mode: 0o700 })
await createDatabaseFile(abs)
this.db = openDatabase(abs, journalMode)
} else {
this.db = openDatabase(path, journalMode)

View File

@@ -1,9 +1,9 @@
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { existsSync } from 'node:fs'
import { mkdtemp, rm } from 'node:fs/promises'
import { chmod, mkdtemp, mkdir, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { dirname, join } from 'node:path'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SurfaceEvent, SurfaceEventType } from '@deepseek-ai/dsh-session'
import SessionPersistenceSqlite, { SCHEMA_VERSION } from '@deepseek-ai/dsh-session-persistence-sqlite'
@@ -348,6 +348,52 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
})
describe('SessionPersistenceSqlite: edge cases', () => {
it('creates a new database and WAL sidecars owner-only without changing an existing directory mode', async () => {
if (process.platform === 'win32') return
const path = await freshDbPath()
const dir = dirname(path)
await chmod(dir, 0o755)
const b = await backend(path)
await b.ctx.sessionPersistence.list()
expect((await stat(dir)).mode & 0o777).toBe(0o755)
expect((await stat(path)).mode & 0o777).toBe(0o600)
expect((await stat(`${path}-wal`)).mode & 0o777).toBe(0o600)
expect((await stat(`${path}-shm`)).mode & 0o777).toBe(0o600)
await b.dispose()
})
it('preserves the mode of an existing database file', async () => {
if (process.platform === 'win32') return
const path = await freshDbPath()
await writeFile(path, '', { mode: 0o644 })
await chmod(path, 0o644)
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(SessionPersistenceSqlite, { path, journalMode: 'delete' })
await ctx.sessionPersistence.list()
expect((await stat(path)).mode & 0o777).toBe(0o644)
await fiber.dispose()
})
it('surfaces database pre-creation errors other than an existing file', async () => {
if (process.platform === 'win32') return
const path = await freshDbPath()
const blocked = join(dirname(path), 'blocked')
await mkdir(blocked, { mode: 0o500 })
const b = await backend(join(blocked, 'sessions.db'))
try {
await expect(b.ctx.sessionPersistence.list()).rejects.toMatchObject({ code: 'EACCES' })
await b.dispose()
} finally {
await chmod(blocked, 0o700)
}
})
it('append rolls back and rethrows when an event INSERT fails inside the transaction', async () => {
const path = await freshDbPath()
const m = meta('rollback-insert')