fix(sqlite): enforce integer session metadata

This commit is contained in:
Hypatia May
2026-07-24 10:35:09 +08:00
parent 3b3a7232a7
commit 19b96037f6
16 changed files with 228 additions and 84 deletions

View File

@@ -129,8 +129,10 @@ function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHe
if (record.id !== id) {
throw new Error(`session header id "${String(record.id)}" does not match session id "${id}"`)
}
if (typeof record.createdAt !== 'number' || !Number.isFinite(record.createdAt)) {
throw new Error('session header createdAt must be a finite number')
if (typeof record.createdAt !== 'number'
|| !Number.isSafeInteger(record.createdAt)
|| record.createdAt < 0) {
throw new Error('session header createdAt must be a non-negative safe integer')
}
if (record.cwd !== undefined) {
if (typeof record.cwd !== 'string') throw new Error('session header cwd must be a string')

View File

@@ -36,7 +36,7 @@ export interface SessionHeader {
readonly version: number
/** The session's id (mirrors the {@link Session}'s id). */
readonly id: SessionId
/** Unix epoch milliseconds when the session was created. */
/** Non-negative safe-integer Unix epoch milliseconds when the session was created. */
readonly createdAt: number
/** Absolute working directory the session was created in (if any). */
readonly cwd?: string

View File

@@ -757,7 +757,7 @@ describe('Session', () => {
{ header: 1, error: /not a plain JSON record/ },
{ header: null, error: /not a plain JSON record/ },
{ header: { ...base, version: 1 }, error: /header version/ },
{ header: { ...base, createdAt: '123' }, error: /createdAt must be a finite number/ },
{ header: { ...base, createdAt: '123' }, error: /createdAt must be a non-negative safe integer/ },
{ header: { ...base, cwd: 1 }, error: /header cwd must be a string/ },
{ header: { ...base, cwd: 'relative' }, error: /header cwd must be an absolute path/ },
{ header: { ...base, parentSession: 1 }, error: /header parentSession must be a string/ },
@@ -962,7 +962,7 @@ describe('SessionStore', () => {
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('plain'))
expect(session.header).toMatchObject({ version: SESSION_FORMAT_VERSION, id: 'plain' })
expect(typeof session.header.createdAt).toBe('number')
expect(Number.isSafeInteger(session.header.createdAt)).toBe(true)
expect(session.header.cwd).toBeUndefined()
expect(session.header.parentSession).toBeUndefined()
})
@@ -1001,7 +1001,10 @@ describe('SessionStore', () => {
{ meta: { parentSession: 1n }, error: /header is not losslessly JSON-serializable/ },
{ meta: { cwd: 1 }, error: /header cwd must be a string/ },
{ meta: { parentSession: 1 }, error: /header parentSession must be a string/ },
{ meta: { createdAt: '123' }, error: /header createdAt must be a finite number/ },
{ meta: { createdAt: '123' }, error: /header createdAt must be a non-negative safe integer/ },
{ meta: { createdAt: 1.5 }, error: /header createdAt must be a non-negative safe integer/ },
{ meta: { createdAt: -1 }, error: /header createdAt must be a non-negative safe integer/ },
{ meta: { createdAt: Number.MAX_SAFE_INTEGER + 1 }, error: /header createdAt must be a non-negative safe integer/ },
{ meta: { seedLength: '1' }, error: /seedLength must be a non-negative safe integer/ },
{ meta: { seedLength: 0.5 }, error: /seedLength must be a non-negative safe integer/ },
{ meta: { seedLength: -1 }, error: /seedLength must be a non-negative safe integer/ },

View File

@@ -84,6 +84,8 @@ function isHeaderLine(value: unknown): value is HeaderLine {
&& typeof (value as { version?: unknown }).version === 'number'
&& typeof (value as { id?: unknown }).id === 'string'
&& typeof (value as { createdAt?: unknown }).createdAt === 'number'
&& Number.isSafeInteger((value as { createdAt: number }).createdAt)
&& (value as { createdAt: number }).createdAt >= 0
&& typeof (value as { delegationDepth?: unknown }).delegationDepth === 'number'
&& Number.isSafeInteger((value as { delegationDepth: number }).delegationDepth)
&& (value as { delegationDepth: number }).delegationDepth >= 0

View File

@@ -559,6 +559,21 @@ describe('SessionPersistenceJsonl: scanLog unit', () => {
expect(() => scanLog(Buffer.from('{"type":"event"}\n'))).toThrow(/session header/)
})
it.each([
['fractional', 1.5],
['negative', -1],
['unsafe', Number.MAX_SAFE_INTEGER + 1],
])('rejects a session header with a %s createdAt', (_label, createdAt) => {
const log = JSON.stringify({
type: 'session',
version: 0,
id: 'invalid-created-at',
createdAt,
delegationDepth: 0,
}) + '\n'
expect(() => scanLog(Buffer.from(log))).toThrow(/session header/)
})
it.each([
['missing', undefined],
['a string', '1'],

View File

@@ -8,9 +8,9 @@ A SQLite durable session-persistence backend — a second `SessionPersistence` i
## 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; 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. 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 user_version` stores the table-layout version. A fresh empty database is initialized at the current version; nonempty unversioned databases and every other version are rejected because this unreleased format has no migrations. Rejection occurs before changing journal mode or stamping the file.
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.
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.
@@ -55,5 +55,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 an empty new database or the current `SCHEMA_VERSION` opens** — a nonempty unversioned database or any other schema version is rejected rather than migrated (unreleased software; no persisted user data to preserve).
- **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).
- **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).

View File

@@ -17,7 +17,10 @@ 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 = 9
export const SCHEMA_VERSION = 10
/** SQLite application id protecting unrelated databases from persistence writes. */
export const SESSION_PERSISTENCE_SQLITE_APPLICATION_ID = 0x44534850
/**
* A row of the `sessions` table — the out-of-log metadata ({@link SessionHeader}).
@@ -86,53 +89,77 @@ function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalM
db.exec('PRAGMA foreign_keys = ON')
// `PRAGMA user_version` always returns exactly one row { user_version }.
const { user_version: onDisk } = db.prepare('PRAGMA user_version').get() as { user_version: number }
const { count: userTableCount } = db.prepare(
"SELECT COUNT(*) AS count FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%'",
const { application_id: applicationId } = db.prepare('PRAGMA application_id').get() as { application_id: number }
const { count: userObjectCount } = db.prepare(
"SELECT COUNT(*) AS count FROM sqlite_schema WHERE name NOT LIKE 'sqlite_%'",
).get() as { count: number }
if (onDisk === 0 && userTableCount > 0) {
throw new Error(`session database at "${path}" has a nonempty unversioned schema`)
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) {
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) {
throw new Error(
`session database at "${path}" has application id ${applicationId}, expected ${SESSION_PERSISTENCE_SQLITE_APPLICATION_ID}`,
)
}
// The validated union is safe to interpolate into a non-bindable PRAGMA.
// Apply it only after rejecting incompatible existing databases.
db.exec(`PRAGMA journal_mode = ${journalMode.toUpperCase()}`)
db.exec(`
CREATE TABLE IF NOT EXISTS persistence_state (
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
store_id TEXT NOT NULL
) STRICT
`)
db.prepare(
'INSERT OR IGNORE INTO persistence_state (singleton, store_id) VALUES (1, ?)',
).run(randomUUID())
db.exec(`
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
version INTEGER NOT NULL,
created_at REAL NOT NULL,
cwd TEXT,
parent_session TEXT,
seed_length INTEGER,
delegation_depth INTEGER,
incarnation TEXT NOT NULL,
revision INTEGER NOT NULL
) STRICT
`)
db.exec(`
CREATE TABLE IF NOT EXISTS 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
`)
if (onDisk === 0) db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`)
let began = false
try {
db.exec('BEGIN IMMEDIATE')
began = true
db.exec(`
CREATE TABLE IF NOT EXISTS persistence_state (
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
store_id TEXT NOT NULL
) STRICT;
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
version INTEGER NOT NULL,
created_at INTEGER NOT NULL,
cwd TEXT,
parent_session TEXT,
seed_length INTEGER,
delegation_depth INTEGER,
incarnation TEXT NOT NULL,
revision INTEGER NOT NULL
) STRICT;
CREATE TABLE IF NOT EXISTS 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
`)
db.prepare(
'INSERT OR IGNORE INTO persistence_state (singleton, store_id) VALUES (1, ?)',
).run(randomUUID())
if (onDisk === 0) {
db.exec(`PRAGMA application_id = ${SESSION_PERSISTENCE_SQLITE_APPLICATION_ID}`)
db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`)
}
db.exec('COMMIT')
} catch (error: unknown) {
/* v8 ignore next -- a BEGIN failure leaves no transaction to roll back. */
if (began) {
/* v8 ignore next 5 -- preserve the original schema failure if SQLite also refuses rollback. */
try {
db.exec('ROLLBACK')
} catch {
// The original SQLite failure remains the actionable cause.
}
}
throw error
}
}
/**
@@ -141,6 +168,9 @@ function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalM
* @returns the header, `NULL` columns mapped to omitted optional fields.
*/
export function rowToMeta(row: SessionRow): SessionHeader {
if (!Number.isSafeInteger(row.created_at) || row.created_at < 0) {
throw new Error('stored session createdAt must be a non-negative safe integer')
}
return {
version: row.version,
id: row.id as SessionId,

View File

@@ -8,7 +8,14 @@ import { DatabaseSync } from 'node:sqlite'
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'
import { openDatabase, rowToEvent, scanRows, type EventRow } from '../src/schema.ts'
import {
openDatabase,
rowToEvent,
rowToMeta,
scanRows,
SESSION_PERSISTENCE_SQLITE_APPLICATION_ID,
type EventRow,
} from '../src/schema.ts'
import { runPersistenceContract, meta, oneTurnLog, appendLog } from '../../session-persistence/tests/contract.ts'
import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts'
@@ -151,6 +158,22 @@ describe('scanRows', () => {
})
})
describe('rowToMeta', () => {
it('rejects fractional stored creation metadata', () => {
expect(() => rowToMeta({
id: 'fractional',
version: 0,
created_at: 1.5,
cwd: null,
parent_session: null,
seed_length: null,
incarnation: 'fractional',
revision: 1,
delegation_depth: null,
})).toThrow('stored session createdAt must be a non-negative safe integer')
})
})
describe('SessionPersistenceSqlite: durability and crash semantics', () => {
it('rejects a stored v0 log containing a legacy request/header-delta event', async () => {
const path = await freshDbPath()
@@ -305,13 +328,13 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
expect(() => openDatabase(olderPath, 'wal')).toThrow(/incompatible with this build/)
})
it('rejects a nonempty unversioned database before stamping or changing journal mode', async () => {
it('rejects a table-backed unversioned database before stamping or changing journal mode', async () => {
const path = await freshDbPath()
const legacy = new DatabaseSync(path)
legacy.exec('CREATE TABLE sessions (id TEXT PRIMARY KEY)')
legacy.close()
expect(() => openDatabase(path, 'wal')).toThrow(/nonempty unversioned schema/)
expect(() => openDatabase(path, 'wal')).toThrow(/unversioned schema or application identity/)
const unchanged = new DatabaseSync(path)
expect(unchanged.prepare('PRAGMA user_version').get()).toEqual({ user_version: 0 })
@@ -322,6 +345,86 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
unchanged.close()
})
it('rejects view-only and foreign-application unversioned databases without mutation', async () => {
const viewPath = await freshDbPath()
const viewOnly = new DatabaseSync(viewPath)
viewOnly.exec('CREATE VIEW foreign_view AS SELECT 1 AS value')
viewOnly.close()
expect(() => openDatabase(viewPath, 'wal')).toThrow(/unversioned schema or application identity/)
const unchangedView = new DatabaseSync(viewPath)
expect(unchangedView.prepare('PRAGMA journal_mode').get()).toEqual({ journal_mode: 'delete' })
expect(unchangedView.prepare(
"SELECT type FROM sqlite_schema WHERE name = 'foreign_view'",
).get()).toEqual({ type: 'view' })
unchangedView.close()
const applicationPath = await freshDbPath()
const foreignApplication = new DatabaseSync(applicationPath)
foreignApplication.exec('PRAGMA application_id = 12345')
foreignApplication.close()
expect(() => openDatabase(applicationPath, 'wal')).toThrow(/unversioned schema or application identity/)
const unchangedApplication = new DatabaseSync(applicationPath)
expect(unchangedApplication.prepare('PRAGMA application_id').get()).toEqual({ application_id: 12345 })
expect(unchangedApplication.prepare('PRAGMA user_version').get()).toEqual({ user_version: 0 })
expect(unchangedApplication.prepare('PRAGMA journal_mode').get()).toEqual({ journal_mode: 'delete' })
unchangedApplication.close()
})
it('rejects a current-version database with a foreign application identity', async () => {
const path = await freshDbPath()
const foreign = new DatabaseSync(path)
foreign.exec('PRAGMA application_id = 12345')
foreign.exec(`PRAGMA user_version = ${SCHEMA_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 journal_mode').get()).toEqual({ journal_mode: 'delete' })
unchanged.close()
})
it('rolls back schema objects and identity stamps when initialization fails', async () => {
const path = await freshDbPath()
const conflicting = new DatabaseSync(path)
conflicting.exec(`PRAGMA application_id = ${SESSION_PERSISTENCE_SQLITE_APPLICATION_ID}`)
conflicting.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`)
conflicting.exec("CREATE VIEW persistence_state AS SELECT 1 AS singleton, 'foreign' AS store_id")
conflicting.close()
expect(() => openDatabase(path, 'wal')).toThrow()
const unchanged = new DatabaseSync(path)
expect(unchanged.prepare(
"SELECT type FROM sqlite_schema WHERE name = 'persistence_state'",
).get()).toEqual({ type: 'view' })
expect(unchanged.prepare(
"SELECT type FROM sqlite_schema WHERE name = 'sessions'",
).get()).toBeUndefined()
expect(unchanged.prepare(
"SELECT type FROM sqlite_schema WHERE name = 'events'",
).get()).toBeUndefined()
expect(unchanged.prepare('PRAGMA application_id').get())
.toEqual({ application_id: SESSION_PERSISTENCE_SQLITE_APPLICATION_ID })
expect(unchanged.prepare('PRAGMA user_version').get()).toEqual({ user_version: SCHEMA_VERSION })
unchanged.close()
})
it('stamps the persistence application identity with the schema version', async () => {
const path = await freshDbPath()
openDatabase(path, 'wal').close()
const db = new DatabaseSync(path)
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 })
db.close()
})
it('rejects a sibling v3 database (the merge-collided version) rather than opening it against missing columns', async () => {
// Version 3 identified two incompatible sibling layouts, so it is always rejected.
const path = await freshDbPath()
@@ -460,7 +563,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
})
it('exposes the schema version constant', () => {
expect(SCHEMA_VERSION).toBe(9)
expect(SCHEMA_VERSION).toBe(10)
})
it('keeps the revision stable for an empty repair hook', async () => {

View File

@@ -178,6 +178,9 @@ export class PersistenceCoordinator<TornMarker = unknown> {
if (snapshot === undefined) {
return Promise.reject(new TypeError('session metadata must be losslessly JSON-serializable'))
}
if (!Number.isSafeInteger(snapshot.createdAt) || snapshot.createdAt < 0) {
return Promise.reject(new TypeError('session metadata createdAt must be a non-negative safe integer'))
}
return this.serialize(snapshot.id, () => this.createCore(snapshot))
}

View File

@@ -84,15 +84,17 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
}
})
it('round-trips a finite fractional creation timestamp', async () => {
it('rejects a fractional creation timestamp without reserving its session id', async () => {
const { persistence, dispose } = await make()
try {
const m = { ...meta('fractional-created-at'), createdAt: 1.5 }
await persistence.create(m)
await persistence.append(m.id, oneTurnLog())
await expect(persistence.create(m))
.rejects.toThrow('session metadata createdAt must be a non-negative safe integer')
const loaded = await persistence.load(m.id)
expect(loaded.meta.createdAt).toBe(1.5)
const valid = meta('fractional-created-at')
await persistence.create(valid)
await persistence.append(valid.id, oneTurnLog())
expect((await persistence.load(valid.id)).meta.createdAt).toBe(valid.createdAt)
} finally {
await dispose()
}

View File

@@ -5,7 +5,7 @@ import { mkdir, open } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
/** Current derived-index schema version. Incompatible versions reset in place. */
export const SESSION_QUERY_SQLITE_SCHEMA_VERSION = 4
export const SESSION_QUERY_SQLITE_SCHEMA_VERSION = 5
/** SQLite application id protecting unrelated databases from derived resets. */
export const SESSION_QUERY_SQLITE_APPLICATION_ID = 0x44534851
@@ -112,7 +112,7 @@ function ensurePersistentSchema(db: DatabaseSync): void {
CREATE TABLE IF NOT EXISTS persisted_sessions (
id TEXT PRIMARY KEY,
version INTEGER NOT NULL,
created_at REAL NOT NULL,
created_at INTEGER NOT NULL,
cwd TEXT,
parent_session TEXT,
seed_length INTEGER,
@@ -141,7 +141,7 @@ function ensureTemporarySchema(db: DatabaseSync): void {
CREATE TEMP TABLE IF NOT EXISTS live_sessions (
id TEXT PRIMARY KEY,
version INTEGER NOT NULL,
created_at REAL NOT NULL,
created_at INTEGER NOT NULL,
cwd TEXT,
parent_session TEXT,
seed_length INTEGER,

View File

@@ -167,22 +167,6 @@ async function liveContext(config: ConstructorParameters<typeof SessionQuerySqli
}
describe('SQLite session search', () => {
it('indexes finite fractional creation timestamps from live and persisted sources', async () => {
const persisted = header('fractional-persisted', 1.5)
TestPersistence.reset([{ meta: persisted, events: messageEvents('persisted fractional') }])
const ctx = await liveContext()
await ctx.plugin(TestPersistence)
const live = ctx.sessions.create(SessionId('fractional-live'), {
seed: messageEvents('live fractional'),
meta: { createdAt: 2.5 },
})
await expect(ctx.sessionQuery.searchSessions({ query: 'persisted' }))
.resolves.toMatchObject({ items: [{ header: { id: persisted.id, createdAt: 1.5 } }] })
await expect(ctx.sessionQuery.searchSessions({ query: 'live' }))
.resolves.toMatchObject({ items: [{ header: { id: live.id, createdAt: 2.5 } }] })
})
it('searches two-character Unicode61 tokens in live-only sessions', async () => {
const ctx = await liveContext({ path: ':memory:', snippetChars: 20 })
const session = ctx.sessions.create(SessionId('live'), {