feat(session): persist optional time zones
This commit is contained in:
@@ -10,9 +10,9 @@ A SQLite durable session-persistence backend — a second `SessionPersistence` p
|
||||
|
||||
## Storage model
|
||||
|
||||
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](../../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md)). Out-of-log metadata (`SessionHeader`), a per-materialization incarnation id, and a monotonic per-log revision live in a `sessions` row; `createdAt` is a non-negative safe integer stored in a strict `INTEGER` column. A singleton state row carries the immutable store id. 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).
|
||||
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](../../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md)). Out-of-log metadata (`SessionHeader`), a per-materialization incarnation id, and a monotonic per-log revision live in a `sessions` row; `createdAt` is a non-negative safe integer stored in a strict `INTEGER` column, and nullable `time_zone` preserves an optional `timeZone` string. A singleton state row carries the immutable store id. 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).
|
||||
|
||||
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 application_id` identifies the canonical persistence database, and `PRAGMA user_version` stores its layout version. A fresh database must have no application identity or user-defined schema objects; initialization creates every table and stamps both pragmas in one transaction. Non-pristine unversioned databases, foreign application identities, and every non-current version reject before journal-mode mutation 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). `PRAGMA application_id` identifies the canonical persistence database, and `PRAGMA user_version` stores its layout version. A fresh database must have no application identity or user-defined schema objects; initialization creates every table and stamps both pragmas in one transaction. The one supported upgrade accepts an owned v13 database, adds nullable `time_zone`, and advances `user_version` to 14 inside the existing `BEGIN IMMEDIATE`; old rows remain `NULL`. A failure rolls back both changes. Non-pristine unversioned databases, foreign application identities, and every other version reject before journal-mode mutation.
|
||||
|
||||
On filesystems with POSIX modes, the backend requests mode `0700` for missing directories and exclusively creates a missing database with mode `0600` before SQLite opens it; the process umask may further restrict both. New WAL, shared-memory, and persistent rollback-journal sidecars receive the database's resulting owner-only mode. Existing directories, database files, and sidecars keep their modes; filesystem setup errors other than an existing database fail initialization. These defaults prevent incidental exposure through a permissive process umask, but do not protect database confidentiality or integrity when another principal can replace the database entry in its parent directory.
|
||||
|
||||
@@ -59,5 +59,5 @@ SQLite storage does not mutate live request prefixes. A resumed loop can reuse p
|
||||
|
||||
- **`DatabaseSync` is synchronous** — every append transaction blocks the event loop for its duration; acceptable for local stores, a throughput ceiling for busy multi-session servers.
|
||||
- **Write contention has no wait or retry policy** — the backend sets no busy timeout and retries no locked-database error, so another connection holding a write transaction makes the operation reject immediately.
|
||||
- **Only a pristine new database or the current owned `SCHEMA_VERSION` opens** — unversioned schema objects, foreign application identities, and every other schema version are rejected rather than migrated (unreleased software; no persisted user data to preserve).
|
||||
- **Only a pristine new database, an owned v13 database eligible for the v14 upgrade, or the current owned `SCHEMA_VERSION` opens** — unversioned schema objects, foreign application identities, and every other schema version are rejected.
|
||||
- **Nothing deletes stored sessions** — rows accumulate until removed externally (the seam has no deletion surface; `ON DELETE CASCADE` is wired for such out-of-band cleanup).
|
||||
|
||||
@@ -10,9 +10,9 @@ SQLite 持久会话存储后端:第二个 `SessionPersistence` 提供方(见
|
||||
|
||||
## 存储模型
|
||||
|
||||
每个 `SessionEvent` 1:1 映射到 `events` 表中的一行 `(session_id, seq, type, time, data, source_event_seqs, surface_op)`;`data` 是作为 JSON 文本的事件 payload,因此行结构就是原始事件本身(包括 `assistant/chunk`,保持 `seq` 连续)。两个 `TEXT` 列 `source_event_seqs` 和 `surface_op` 可为空,存储事件可选接口元数据字段(见[会话接口](../../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md))。日志外元数据(`SessionHeader`)、每实体化 incarnation id 和每日志单调修订位于 `sessions` 行;`createdAt` 是存储在 strict `INTEGER` 列中的非负安全整数。单例状态行携带不可变存储 id。`sessions` 行只由第一次 `append` 写入,其存在性是延迟实体化信号(`list` 精确报告有行的会话)。
|
||||
每个 `SessionEvent` 1:1 映射到 `events` 表中的一行 `(session_id, seq, type, time, data, source_event_seqs, surface_op)`;`data` 是作为 JSON 文本的事件 payload,因此行结构就是原始事件本身(包括 `assistant/chunk`,保持 `seq` 连续)。两个 `TEXT` 列 `source_event_seqs` 和 `surface_op` 可为空,存储事件可选接口元数据字段(见[会话接口](../../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md))。日志外元数据(`SessionHeader`)、每实体化 incarnation id 和每日志单调修订位于 `sessions` 行;`createdAt` 是存储在 strict `INTEGER` 列中的非负安全整数,可为空的 `time_zone` 则保留可选的 `timeZone` 字符串。单例状态行携带不可变存储 id。`sessions` 行只由第一次 `append` 写入,其存在性是延迟实体化信号(`list` 精确报告有行的会话)。
|
||||
|
||||
仓库支持的 Node 范围可不加 flag 使用 `node:sqlite`。数据库启用外键,并使用已配置 journal mode(默认 `wal`;WAL 共享内存文件不适用时使用 rollback mode)。`PRAGMA application_id` 标识规范持久化数据库,`PRAGMA user_version` 存储布局版本。新数据库必须没有 application identity 或用户定义 schema 对象;初始化在一个事务中创建全部表并盖上两个 pragma。非 pristine 无版本数据库、外部 application identity 和所有非当前版本在 journal-mode 变更前均会被拒绝,因为该未发布格式无迁移。
|
||||
仓库支持的 Node 范围可不加 flag 使用 `node:sqlite`。数据库启用外键,并使用已配置 journal mode(默认 `wal`;WAL 共享内存文件不适用时使用 rollback mode)。`PRAGMA application_id` 标识规范持久化数据库,`PRAGMA user_version` 存储布局版本。新数据库必须没有 application identity 或用户定义 schema 对象;初始化在一个事务中创建全部表并盖上两个 pragma。唯一受支持的升级接受自有 v13 数据库,在既有 `BEGIN IMMEDIATE` 中添加可为空的 `time_zone`,并将 `user_version` 推进到 14;旧行保持 `NULL`。失败会回滚这两项变更。非 pristine 无版本数据库、外部 application identity 和所有其他版本在 journal-mode 变更前均会被拒绝。
|
||||
|
||||
在具有 POSIX mode 的文件系统上,后端为缺失目录请求 mode `0700`,并在 SQLite 打开前以 mode `0600` 排他创建缺失数据库;进程 umask 可进一步限制两者。新 WAL、共享内存和持久 rollback-journal sidecar 获得数据库最终的仅所有者 mode。现有目录、数据库文件和 sidecar 保留原 mode;除已存在数据库外的文件系统设置错误会使初始化失败。这些默认值防止宽松进程 umask 造成的意外暴露,但当其他 principal 能替换父目录中的数据库条目时,不保护数据库机密性或完整性。
|
||||
|
||||
@@ -59,5 +59,5 @@ SQLite 存储不修改当前请求前缀。只有重建历史、当前 envelope
|
||||
|
||||
- **`DatabaseSync` 是同步的**:每个 append 事务在整个期间阻塞事件循环;对本地存储可接受,对繁忙多会话服务器是吞吐上限。
|
||||
- **写入争用无等待或重试策略**:后端不设置 busy timeout,也不重试 locked-database 错误,因此其他连接持有写事务时操作立即拒绝。
|
||||
- **只有 pristine 新数据库或当前自有 `SCHEMA_VERSION` 才能打开**:无版本 schema 对象、外部 application identity 和所有其他 schema 版本被拒绝,而不是迁移(未发布软件,无持久用户数据需要保留)。
|
||||
- **只有 pristine 新数据库、符合 v14 升级条件的自有 v13 数据库或当前自有 `SCHEMA_VERSION` 才能打开**:无版本 schema 对象、外部 application identity 和所有其他 schema 版本都会被拒绝。
|
||||
- **不删除已存储会话**:行会累积,直到外部移除(seam 无删除接口;`ON DELETE CASCADE` 已为这种带外清理配置)。
|
||||
|
||||
@@ -380,12 +380,13 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
|
||||
private writeRow(meta: SessionHeader): void {
|
||||
this.db.prepare(`
|
||||
INSERT INTO sessions
|
||||
(id, version, created_at, cwd, parent_session, seed_length, origin, delegation_depth, incarnation, revision)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
|
||||
(id, version, created_at, cwd, time_zone, parent_session, seed_length, origin, delegation_depth, incarnation, revision)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
version = excluded.version,
|
||||
created_at = excluded.created_at,
|
||||
cwd = excluded.cwd,
|
||||
time_zone = excluded.time_zone,
|
||||
parent_session = excluded.parent_session,
|
||||
seed_length = excluded.seed_length,
|
||||
origin = excluded.origin,
|
||||
@@ -395,6 +396,7 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
|
||||
meta.version,
|
||||
meta.createdAt,
|
||||
meta.cwd ?? null,
|
||||
meta.timeZone ?? null,
|
||||
meta.parentSession ?? null,
|
||||
meta.seedLength ?? null,
|
||||
meta.origin ?? null,
|
||||
|
||||
@@ -17,7 +17,55 @@ import type { SessionEvent, SessionId, SessionHeader, SurfaceOp } from '@deepsee
|
||||
* layout; orthogonal to a session's own `version` (which versions the EVENT
|
||||
* vocabulary, stored per session in the `sessions` row).
|
||||
*/
|
||||
export const SCHEMA_VERSION = 13
|
||||
export const SCHEMA_VERSION = 14
|
||||
|
||||
/** The one owned schema layout this build upgrades in place. */
|
||||
const MIGRATABLE_SCHEMA_VERSION = 13
|
||||
|
||||
/** Exact user objects emitted by the v13 schema owner, before `time_zone`. */
|
||||
const MIGRATABLE_V13_SCHEMA = [
|
||||
{
|
||||
type: 'table',
|
||||
name: 'events',
|
||||
tableName: 'events',
|
||||
sql: `CREATE TABLE events (
|
||||
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
seq INTEGER NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
time INTEGER NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
source_event_seqs TEXT,
|
||||
surface_op TEXT,
|
||||
PRIMARY KEY (session_id, seq)
|
||||
) STRICT`,
|
||||
},
|
||||
{
|
||||
type: 'table',
|
||||
name: 'persistence_state',
|
||||
tableName: 'persistence_state',
|
||||
sql: `CREATE TABLE persistence_state (
|
||||
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||
store_id TEXT NOT NULL
|
||||
) STRICT`,
|
||||
},
|
||||
{
|
||||
type: 'table',
|
||||
name: 'sessions',
|
||||
tableName: 'sessions',
|
||||
sql: `CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
version INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
cwd TEXT,
|
||||
parent_session TEXT,
|
||||
seed_length INTEGER,
|
||||
origin TEXT,
|
||||
delegation_depth INTEGER,
|
||||
incarnation TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL
|
||||
) STRICT`,
|
||||
},
|
||||
] as const
|
||||
|
||||
/** SQLite application id protecting unrelated databases from persistence writes. */
|
||||
export const SESSION_PERSISTENCE_SQLITE_APPLICATION_ID = 0x44534850
|
||||
@@ -34,6 +82,7 @@ export interface SessionRow {
|
||||
version: number
|
||||
created_at: number
|
||||
cwd: string | null
|
||||
time_zone: string | null
|
||||
parent_session: string | null
|
||||
seed_length: number | null
|
||||
origin: 'subagent' | null
|
||||
@@ -68,9 +117,9 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
|
||||
|
||||
/**
|
||||
* Open the database and apply its schema and pragmas. An empty database with a
|
||||
* zero `user_version` is initialized at {@link SCHEMA_VERSION}; a nonempty
|
||||
* unversioned database and every other non-current version reject rather than
|
||||
* being migrated in place.
|
||||
* zero `user_version` is initialized at {@link SCHEMA_VERSION}; an owned v13
|
||||
* database is upgraded atomically, while a nonempty unversioned database and
|
||||
* every other non-current version reject.
|
||||
* @param path - the SQLite database file to open (created when absent).
|
||||
* @param journalMode - validated journal pragma.
|
||||
* @returns the open handle with pragmas applied and all three tables ensured.
|
||||
@@ -102,14 +151,19 @@ function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalM
|
||||
if (onDisk === 0 && (applicationId !== 0 || userObjectCount > 0)) {
|
||||
throw new Error(`session database at "${path}" has an unversioned schema or application identity`)
|
||||
}
|
||||
if (onDisk !== 0 && onDisk !== SCHEMA_VERSION) {
|
||||
if (onDisk !== 0 && onDisk !== MIGRATABLE_SCHEMA_VERSION && onDisk !== SCHEMA_VERSION) {
|
||||
throw new Error(`session database at "${path}" has schema version ${onDisk}, incompatible with this build (${SCHEMA_VERSION})`)
|
||||
}
|
||||
if (onDisk === SCHEMA_VERSION && applicationId !== SESSION_PERSISTENCE_SQLITE_APPLICATION_ID) {
|
||||
if ((onDisk === MIGRATABLE_SCHEMA_VERSION || onDisk === SCHEMA_VERSION)
|
||||
&& applicationId !== SESSION_PERSISTENCE_SQLITE_APPLICATION_ID) {
|
||||
throw new Error(
|
||||
`session database at "${path}" has application id ${applicationId}, expected ${SESSION_PERSISTENCE_SQLITE_APPLICATION_ID}`,
|
||||
)
|
||||
}
|
||||
if (onDisk === MIGRATABLE_SCHEMA_VERSION) {
|
||||
assertMigratableV13Schema(db, path)
|
||||
db.exec('ALTER TABLE sessions ADD COLUMN time_zone TEXT')
|
||||
}
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS persistence_state (
|
||||
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||
@@ -121,6 +175,7 @@ function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalM
|
||||
version INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
cwd TEXT,
|
||||
time_zone TEXT,
|
||||
parent_session TEXT,
|
||||
seed_length INTEGER,
|
||||
origin TEXT,
|
||||
@@ -145,6 +200,8 @@ function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalM
|
||||
).run(randomUUID())
|
||||
if (onDisk === 0) {
|
||||
db.exec(`PRAGMA application_id = ${SESSION_PERSISTENCE_SQLITE_APPLICATION_ID}`)
|
||||
}
|
||||
if (onDisk === 0 || onDisk === MIGRATABLE_SCHEMA_VERSION) {
|
||||
db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`)
|
||||
}
|
||||
db.exec('COMMIT')
|
||||
@@ -166,6 +223,34 @@ function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalM
|
||||
db.exec(`PRAGMA journal_mode = ${journalMode.toUpperCase()}`)
|
||||
}
|
||||
|
||||
/** Reject spoofed or modified v13 layouts before the migration changes them. */
|
||||
function assertMigratableV13Schema(db: DatabaseSync, path: string): void {
|
||||
const objects = db.prepare(`
|
||||
SELECT type, name, tbl_name AS tableName, sql
|
||||
FROM sqlite_schema
|
||||
WHERE name NOT GLOB 'sqlite_*'
|
||||
ORDER BY type, name
|
||||
`).all() as Array<{ type: string; name: string; tableName: string; sql: string | null }>
|
||||
const matches = objects.length === MIGRATABLE_V13_SCHEMA.length
|
||||
&& objects.every((object, index) => {
|
||||
const expected = MIGRATABLE_V13_SCHEMA[index]
|
||||
return expected !== undefined
|
||||
&& object.type === expected.type
|
||||
&& object.name === expected.name
|
||||
&& object.tableName === expected.tableName
|
||||
&& object.sql !== null
|
||||
&& normalizeSchemaSql(object.sql) === normalizeSchemaSql(expected.sql)
|
||||
})
|
||||
if (!matches) {
|
||||
throw new Error(`session database at "${path}" does not match the owned v13 schema`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Ignore formatting while preserving every schema token and its order. */
|
||||
function normalizeSchemaSql(sql: string): string {
|
||||
return sql.replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct the {@link SessionHeader} from a `sessions` row.
|
||||
* @param row - the `sessions` table row.
|
||||
@@ -180,6 +265,7 @@ export function rowToMeta(row: SessionRow): SessionHeader {
|
||||
id: row.id as SessionId,
|
||||
createdAt: row.created_at,
|
||||
...row.cwd !== null ? { cwd: row.cwd } : {},
|
||||
...row.time_zone !== null ? { timeZone: row.time_zone } : {},
|
||||
...row.parent_session !== null ? { parentSession: row.parent_session as SessionId } : {},
|
||||
...row.seed_length !== null ? { seedLength: row.seed_length } : {},
|
||||
...row.origin !== null ? { origin: row.origin } : {},
|
||||
|
||||
@@ -40,6 +40,48 @@ async function freshDbPath(): Promise<string> {
|
||||
return join(dir, 'sessions.db')
|
||||
}
|
||||
|
||||
/** Create the exact owned v13 layout without passing through the v14 opener. */
|
||||
function createV13Database(path: string): DatabaseSync {
|
||||
const db = new DatabaseSync(path)
|
||||
db.exec(`
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE persistence_state (
|
||||
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||
store_id TEXT NOT NULL
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
version INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
cwd TEXT,
|
||||
parent_session TEXT,
|
||||
seed_length INTEGER,
|
||||
origin TEXT,
|
||||
delegation_depth INTEGER,
|
||||
incarnation TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE events (
|
||||
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
seq INTEGER NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
time INTEGER NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
source_event_seqs TEXT,
|
||||
surface_op TEXT,
|
||||
PRIMARY KEY (session_id, seq)
|
||||
) STRICT;
|
||||
|
||||
PRAGMA application_id = ${SESSION_PERSISTENCE_SQLITE_APPLICATION_ID};
|
||||
PRAGMA user_version = 13;
|
||||
`)
|
||||
db.prepare('INSERT INTO persistence_state (singleton, store_id) VALUES (1, ?)').run('v13-fixture-store')
|
||||
return db
|
||||
}
|
||||
|
||||
/** A context with the session store + SQLite backend, plus a teardown. */
|
||||
async function backend(path = ':memory:'): Promise<{ ctx: Context; dispose: () => Promise<void> }> {
|
||||
const ctx = new Context()
|
||||
@@ -166,13 +208,14 @@ describe('rowToMeta', () => {
|
||||
version: 0,
|
||||
created_at: 1,
|
||||
cwd: null,
|
||||
time_zone: 'Asia/Shanghai',
|
||||
parent_session: null,
|
||||
seed_length: null,
|
||||
origin: 'subagent',
|
||||
incarnation: 'with-origin',
|
||||
revision: 1,
|
||||
delegation_depth: null,
|
||||
})).toMatchObject({ id: 'with-origin', origin: 'subagent' })
|
||||
})).toMatchObject({ id: 'with-origin', origin: 'subagent', timeZone: 'Asia/Shanghai' })
|
||||
})
|
||||
|
||||
it('rejects fractional stored creation metadata', () => {
|
||||
@@ -181,6 +224,7 @@ describe('rowToMeta', () => {
|
||||
version: 0,
|
||||
created_at: 1.5,
|
||||
cwd: null,
|
||||
time_zone: null,
|
||||
parent_session: null,
|
||||
seed_length: null,
|
||||
origin: null,
|
||||
@@ -328,7 +372,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
|
||||
await b2.dispose()
|
||||
})
|
||||
|
||||
it('rejects opening a database whose schema version is not the current build (newer OR older)', async () => {
|
||||
it('rejects opening a database whose schema version is neither v13 nor the current build', async () => {
|
||||
const path = await freshDbPath()
|
||||
openDatabase(path, 'wal').close() // stamp user_version = SCHEMA_VERSION
|
||||
// Bump user_version past what this build supports.
|
||||
@@ -337,16 +381,84 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
|
||||
dbNewer.close()
|
||||
expect(() => openDatabase(path, 'wal')).toThrow(/incompatible with this build/)
|
||||
|
||||
// The immediately preceding layout lacks the required store identity and is
|
||||
// rejected rather than migrated (unreleased software, no backward-compat).
|
||||
// Versions older than the one explicit migration remain unsupported.
|
||||
const olderPath = await freshDbPath()
|
||||
openDatabase(olderPath, 'wal').close()
|
||||
const dbOlder = openDatabase(olderPath, 'wal')
|
||||
dbOlder.exec(`PRAGMA user_version = ${SCHEMA_VERSION - 1}`)
|
||||
dbOlder.exec(`PRAGMA user_version = ${SCHEMA_VERSION - 2}`)
|
||||
dbOlder.close()
|
||||
expect(() => openDatabase(olderPath, 'wal')).toThrow(/incompatible with this build/)
|
||||
})
|
||||
|
||||
it('atomically migrates an owned v13 fixture and leaves old rows headerless', async () => {
|
||||
const path = await freshDbPath()
|
||||
const old = meta('v13-headerless', '/work')
|
||||
const legacy = createV13Database(path)
|
||||
legacy.prepare(`
|
||||
INSERT INTO sessions
|
||||
(id, version, created_at, cwd, parent_session, seed_length, origin, delegation_depth, incarnation, revision)
|
||||
VALUES (?, ?, ?, ?, NULL, NULL, NULL, NULL, ?, 1)
|
||||
`).run(old.id, old.version, old.createdAt, old.cwd ?? null, 'v13-headerless-incarnation')
|
||||
const insertEvent = legacy.prepare(
|
||||
'INSERT INTO events (session_id, seq, type, time, data, source_event_seqs, surface_op) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
)
|
||||
for (const event of oneTurnLog()) {
|
||||
const surface = event as SessionEvent<SurfaceEventType>
|
||||
insertEvent.run(
|
||||
old.id,
|
||||
event.seq,
|
||||
event.type,
|
||||
event.time,
|
||||
JSON.stringify(event.data),
|
||||
surface.sourceEventSeqs !== undefined ? JSON.stringify(surface.sourceEventSeqs) : null,
|
||||
surface.surfaceOp !== undefined ? JSON.stringify(surface.surfaceOp) : null,
|
||||
)
|
||||
}
|
||||
legacy.close()
|
||||
|
||||
const migrated = openDatabase(path, 'wal')
|
||||
expect(migrated.prepare('PRAGMA user_version').get()).toEqual({ user_version: 14 })
|
||||
expect(migrated.prepare('SELECT time_zone FROM sessions WHERE id = ?').get(old.id))
|
||||
.toEqual({ time_zone: null })
|
||||
migrated.close()
|
||||
|
||||
const mounted = await backend(path)
|
||||
try {
|
||||
const loaded = await mounted.ctx.sessionPersistence.load(old.id)
|
||||
expect(loaded.meta.timeZone).toBeUndefined()
|
||||
expect(loaded.events).toEqual(oneTurnLog())
|
||||
|
||||
const zoned = meta('v14-zoned', '/work', 'Asia/Shanghai')
|
||||
await mounted.ctx.sessionPersistence.create(zoned)
|
||||
await mounted.ctx.sessionPersistence.append(zoned.id, oneTurnLog())
|
||||
expect((await mounted.ctx.sessionPersistence.load(zoned.id)).meta.timeZone).toBe('Asia/Shanghai')
|
||||
} finally {
|
||||
await mounted.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a spoofed v13 layout without changing its schema or version', async () => {
|
||||
const path = await freshDbPath()
|
||||
const malformed = new DatabaseSync(path)
|
||||
malformed.exec(`
|
||||
CREATE TABLE sessions (id TEXT);
|
||||
PRAGMA application_id = ${SESSION_PERSISTENCE_SQLITE_APPLICATION_ID};
|
||||
PRAGMA user_version = 13;
|
||||
`)
|
||||
malformed.close()
|
||||
|
||||
expect(() => openDatabase(path, 'wal')).toThrow(/does not match the owned v13 schema/)
|
||||
|
||||
const unchanged = new DatabaseSync(path)
|
||||
const columns = unchanged.prepare('PRAGMA table_info(sessions)').all() as Array<{ name: string }>
|
||||
expect(columns.map(column => column.name)).toEqual(['id'])
|
||||
expect(unchanged.prepare('PRAGMA user_version').get()).toEqual({ user_version: 13 })
|
||||
expect(unchanged.prepare(
|
||||
"SELECT name FROM sqlite_schema WHERE name IN ('persistence_state', 'events')",
|
||||
).all()).toEqual([])
|
||||
unchanged.close()
|
||||
})
|
||||
|
||||
it('rejects a table-backed unversioned database before stamping or changing journal mode', async () => {
|
||||
const path = await freshDbPath()
|
||||
const legacy = new DatabaseSync(path)
|
||||
@@ -408,23 +520,23 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
|
||||
unchangedApplication.close()
|
||||
})
|
||||
|
||||
it('rejects a current-version database with a foreign application identity', async () => {
|
||||
it.each([13, SCHEMA_VERSION])('rejects a schema-v%i database with a foreign application identity', async (version) => {
|
||||
const path = await freshDbPath()
|
||||
const foreign = new DatabaseSync(path)
|
||||
foreign.exec('PRAGMA application_id = 12345')
|
||||
foreign.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`)
|
||||
foreign.exec(`PRAGMA user_version = ${version}`)
|
||||
foreign.close()
|
||||
|
||||
expect(() => openDatabase(path, 'wal')).toThrow(/has application id 12345/)
|
||||
|
||||
const unchanged = new DatabaseSync(path)
|
||||
expect(unchanged.prepare('PRAGMA application_id').get()).toEqual({ application_id: 12345 })
|
||||
expect(unchanged.prepare('PRAGMA user_version').get()).toEqual({ user_version: SCHEMA_VERSION })
|
||||
expect(unchanged.prepare('PRAGMA user_version').get()).toEqual({ user_version: version })
|
||||
expect(unchanged.prepare('PRAGMA journal_mode').get()).toEqual({ journal_mode: 'delete' })
|
||||
unchanged.close()
|
||||
})
|
||||
|
||||
it('rolls back schema objects and identity stamps when initialization fails', async () => {
|
||||
it('rolls back tables created before persistence-state initialization fails', async () => {
|
||||
const path = await freshDbPath()
|
||||
const conflicting = new DatabaseSync(path)
|
||||
conflicting.exec(`PRAGMA application_id = ${SESSION_PERSISTENCE_SQLITE_APPLICATION_ID}`)
|
||||
@@ -459,6 +571,11 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
|
||||
expect(db.prepare('PRAGMA application_id').get())
|
||||
.toEqual({ application_id: SESSION_PERSISTENCE_SQLITE_APPLICATION_ID })
|
||||
expect(db.prepare('PRAGMA user_version').get()).toEqual({ user_version: SCHEMA_VERSION })
|
||||
expect(db.prepare('PRAGMA table_info(sessions)').all()).toContainEqual(expect.objectContaining({
|
||||
name: 'time_zone',
|
||||
type: 'TEXT',
|
||||
notnull: 0,
|
||||
}))
|
||||
db.close()
|
||||
})
|
||||
|
||||
@@ -638,7 +755,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
|
||||
})
|
||||
|
||||
it('exposes the schema version constant', () => {
|
||||
expect(SCHEMA_VERSION).toBe(13)
|
||||
expect(SCHEMA_VERSION).toBe(14)
|
||||
})
|
||||
|
||||
it('keeps the revision stable for an empty repair hook', async () => {
|
||||
|
||||
Reference in New Issue
Block a user