feat(session): persist optional time zones
This commit is contained in:
25 files changed
+516
-70
No files matched your search
@@ -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