feat(session): persist optional time zones
This commit is contained in:
@@ -21,12 +21,13 @@ export interface ContractBackend {
|
||||
}
|
||||
|
||||
/** Build a minimal {@link SessionHeader} for a session id. */
|
||||
export function meta(id: string, cwd?: string): SessionHeader {
|
||||
export function meta(id: string, cwd?: string, timeZone?: string): SessionHeader {
|
||||
return {
|
||||
version: SESSION_FORMAT_VERSION,
|
||||
id: SessionId(id),
|
||||
createdAt: 1000,
|
||||
...cwd !== undefined ? { cwd } : {},
|
||||
...timeZone !== undefined ? { timeZone } : {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,19 +87,49 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
|
||||
it('round-trips a session: create + append → load returns identical meta and byte-identical events', async () => {
|
||||
const { persistence, dispose } = await make()
|
||||
try {
|
||||
const m = meta('s1', '/work')
|
||||
const m = meta('s1', '/work', 'Asia/Shanghai')
|
||||
const log = oneTurnLog()
|
||||
await persistence.create(m)
|
||||
await persistence.append(m.id, log)
|
||||
|
||||
const loaded = await persistence.load(m.id)
|
||||
expect(loaded.meta).toMatchObject({ version: SESSION_FORMAT_VERSION, id: m.id, cwd: '/work' })
|
||||
expect(loaded.meta).toMatchObject(m)
|
||||
expect(loaded.events).toEqual(log)
|
||||
} finally {
|
||||
await dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a headerless session headerless across storage reads', async () => {
|
||||
const { persistence, dispose } = await make()
|
||||
try {
|
||||
const m = meta('headerless', '/work')
|
||||
await persistence.create(m)
|
||||
await persistence.append(m.id, oneTurnLog())
|
||||
|
||||
expect((await persistence.inspect(m.id)).meta.timeZone).toBeUndefined()
|
||||
expect((await persistence.load(m.id)).meta.timeZone).toBeUndefined()
|
||||
expect((await persistence.list()).find(header => header.id === m.id)?.timeZone).toBeUndefined()
|
||||
} finally {
|
||||
await dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects non-string timeZone metadata without reserving its session id', async () => {
|
||||
const { persistence, dispose } = await make()
|
||||
try {
|
||||
const invalid = { ...meta('invalid-time-zone'), timeZone: 1 as unknown as string }
|
||||
await expect(persistence.create(invalid)).rejects.toThrow('session metadata timeZone must be a string')
|
||||
|
||||
const valid = meta('invalid-time-zone', undefined, 'UTC')
|
||||
await persistence.create(valid)
|
||||
await persistence.append(valid.id, oneTurnLog())
|
||||
expect((await persistence.load(valid.id)).meta.timeZone).toBe('UTC')
|
||||
} finally {
|
||||
await dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a fractional creation timestamp without reserving its session id', async () => {
|
||||
const { persistence, dispose } = await make()
|
||||
try {
|
||||
|
||||
@@ -908,6 +908,69 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
}
|
||||
})
|
||||
|
||||
it('stored-prefix adoption rejects a different present timeZone', async () => {
|
||||
const fix = await makeFixture()
|
||||
const first = await freshCtx(fix)
|
||||
try {
|
||||
const stored = first.ctx.sessions.create(SessionId('zone-adoption'), {
|
||||
meta: { cwd: WORK, timeZone: 'Asia/Shanghai' },
|
||||
})
|
||||
send(stored, oneTurnLog())
|
||||
await first.ctx.sessions.flush(stored)
|
||||
} finally {
|
||||
await first.fiber.dispose()
|
||||
}
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const live = ctx.sessions.create(SessionId('zone-adoption'), {
|
||||
seed: oneTurnLog(),
|
||||
meta: { cwd: WORK, timeZone: 'America/New_York' },
|
||||
})
|
||||
const second = await fix.mount(ctx)
|
||||
try {
|
||||
await expect(ctx.sessions.flush(live)).rejects.toThrow(/different timeZone|id collision/)
|
||||
} finally {
|
||||
await second.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
await fix.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('stored-prefix adoption keeps a headerless record headerless for a zoned live session', async () => {
|
||||
const fix = await makeFixture()
|
||||
const log = [
|
||||
...oneTurnLog(),
|
||||
{ type: 'session/end-seed', seq: 6, time: 7, data: {} },
|
||||
] as SessionEvent[]
|
||||
const first = await freshCtx(fix)
|
||||
try {
|
||||
const stored = first.ctx.sessions.create(SessionId('headerless-zone-adoption'), {
|
||||
seed: log,
|
||||
meta: { cwd: WORK },
|
||||
})
|
||||
await first.ctx.sessions.flush(stored)
|
||||
} finally {
|
||||
await first.fiber.dispose()
|
||||
}
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const live = ctx.sessions.create(SessionId('headerless-zone-adoption'), {
|
||||
seed: log,
|
||||
meta: { cwd: WORK, timeZone: 'Asia/Shanghai' },
|
||||
})
|
||||
const second = await fix.mount(ctx)
|
||||
try {
|
||||
await expect(ctx.sessions.flush(live)).resolves.toBe(true)
|
||||
expect((await ctx.sessionPersistence.load(live.id)).meta.timeZone).toBeUndefined()
|
||||
} finally {
|
||||
await second.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
await fix.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('HMR: adoption persists the live SUFFIX that was ahead of the stored prefix', async () => {
|
||||
const fix = await makeFixture()
|
||||
const ctx = new Context()
|
||||
@@ -1104,6 +1167,55 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
}
|
||||
})
|
||||
|
||||
it('a zoned live session claims headerless ownerless state without backfilling it', async () => {
|
||||
const fix = await makeFixture()
|
||||
const { ctx, fiber } = await freshCtx(fix)
|
||||
try {
|
||||
await ctx.sessionPersistence.create(meta('headerless-zone-claim', WORK))
|
||||
const live = ctx.sessions.create(SessionId('headerless-zone-claim'), {
|
||||
seed: oneTurnLog(),
|
||||
meta: { cwd: WORK, timeZone: 'Asia/Shanghai' },
|
||||
})
|
||||
|
||||
await expect(ctx.sessions.flush(live)).resolves.toBe(true)
|
||||
expect((await ctx.sessionPersistence.load(live.id)).meta.timeZone).toBeUndefined()
|
||||
} finally {
|
||||
await fiber.dispose()
|
||||
await fix.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('ownerless state with a timeZone only accepts the same live identity', async () => {
|
||||
const fix = await makeFixture()
|
||||
const { ctx, fiber } = await freshCtx(fix)
|
||||
try {
|
||||
await ctx.sessionPersistence.create(meta('same-zone-claim', WORK, 'Asia/Shanghai'))
|
||||
const matching = ctx.sessions.create(SessionId('same-zone-claim'), {
|
||||
seed: oneTurnLog(),
|
||||
meta: { cwd: WORK, timeZone: 'Asia/Shanghai' },
|
||||
})
|
||||
await expect(ctx.sessions.flush(matching)).resolves.toBe(true)
|
||||
expect((await ctx.sessionPersistence.load(matching.id)).meta.timeZone).toBe('Asia/Shanghai')
|
||||
|
||||
await ctx.sessionPersistence.create(meta('different-zone-claim', WORK, 'Asia/Shanghai'))
|
||||
const conflicting = ctx.sessions.create(SessionId('different-zone-claim'), {
|
||||
seed: oneTurnLog(),
|
||||
meta: { cwd: WORK, timeZone: 'America/New_York' },
|
||||
})
|
||||
await expect(ctx.sessions.flush(conflicting)).rejects.toThrow(/different timeZone|id collision/)
|
||||
|
||||
await ctx.sessionPersistence.create(meta('missing-zone-claim', WORK, 'Asia/Shanghai'))
|
||||
const missing = ctx.sessions.create(SessionId('missing-zone-claim'), {
|
||||
seed: oneTurnLog(),
|
||||
meta: { cwd: WORK },
|
||||
})
|
||||
await expect(ctx.sessions.flush(missing)).rejects.toThrow(/different timeZone|id collision/)
|
||||
} finally {
|
||||
await fiber.dispose()
|
||||
await fix.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('a fresh session reusing a previously-loaded id is rejected (ownerless guard)', async () => {
|
||||
const fix = await makeFixture()
|
||||
const { ctx, fiber } = await freshCtx(fix)
|
||||
|
||||
@@ -374,6 +374,28 @@ describe('PersistenceCoordinator bounded writes', () => {
|
||||
})
|
||||
|
||||
describe('PersistenceCoordinator stored identity', () => {
|
||||
it('rejects a non-string timeZone decoded by a backend', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const backend = new ControlledBackend()
|
||||
const id = SessionId('invalid-stored-zone')
|
||||
backend.store.set(id, {
|
||||
meta: { ...meta(id), timeZone: 1 as unknown as string },
|
||||
events: [],
|
||||
})
|
||||
let coordinator!: PersistenceCoordinator<never>
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
coordinator = new PersistenceCoordinator(inner, backend)
|
||||
}, { inject: ['sessions'] }))
|
||||
try {
|
||||
await expect(coordinator.inspect(id)).rejects.toThrow(/stored session .* timeZone must be a string/)
|
||||
expect((coordinator as unknown as CoordinatorInternals).states.size).toBe(0)
|
||||
} finally {
|
||||
await fiber.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a mismatched backend header before repair or state publication', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
|
||||
Reference in New Issue
Block a user