/** * 2c session-sync KERNEL ([ref] A2) — the PURE half of session-sync: the §7 relation classifier, the staging-id * scheme, the {@link StagingHandle}/{@link ImportStagingStore} contracts and the {@link SyncConflictError} type. * Moved VERBATIM out of session-sync.ts (which keeps the StoreBackend-coupled ORCHESTRATION: exportSession/ * importSession/planSync) so the three DB session adapters (local/pg/tidb) and security.ts can depend on these * symbols WITHOUT touching the orchestration module — session-sync.ts value-imports StoreBackend and type-imports * security's OwnerAwareSessionStore, so importing it from security.ts formed the repo's only true type cycle * (security ↔ session-sync; lens2 §B/§E). This file imports NOTHING from the repo (core types only) — a leaf, * same shape as session-sync-content.ts. session-sync.ts re-exports everything here, so existing import sites * keep working unchanged. */ import type { SessionTreeEntry } from "@sema-agent/core"; /** * P1d-β (PUSH streaming, sema-internal server/docs/DESIGN-session-sync.md §15 + the persistence synthesis) — a TWO-PHASE STAGED import * handle. The whole-bundle `importSession` (session-sync.ts) buffers every entry in one JSON body (server memory ∝ session size); * β instead STREAMS the entry log (NDJSON) into a SHADOW session id, validating it ONLINE per line through core's * StreamingImportValidator (the single invariant gate — core 1.143.0), then ATOMICALLY swaps * the shadow rows into the real session id in ONE txn — so server memory is bounded by the in-flight BATCH, not the * whole log. A handle is minted by {@link ImportStagingStore.beginImportStaging} (Phase A) and driven by the NDJSON * route (Phase B): `appendBatch` per batch, then `commit` (the swap) or `abort` (drop the staging rows). * * Invariants the implementations uphold: * - The staging rows live under `stagingId = ${realSessionId}#stg-${token}` (the `#` is illegal in a real uuidv7, so * it can NEVER collide with a real session id) and have NO `session_meta` row → they are INVISIBLE to wake / ownerOf * / listSessions (the existing orphan invariant) until `commit` writes the real session_meta LAST as the sole * commit point. A crash before commit leaves only collectable orphan staging rows, never a half-session. * - `appendBatch` is IDEMPOTENT (INSERT … ON DUPLICATE KEY / ON CONFLICT DO NOTHING on the `(session_id, entry_id)` * uk), so a re-streamed batch (a resumed Phase B) is a no-op. * - `commit` re-classifies the relation IN-TXN against the dst's CURRENT entry ids (closing the Phase-A plan→commit * TOCTOU): a flip to fork/stale without the caller's overwrite consent rolls back and throws {@link SyncConflictError}. */ export interface StagingHandle { /** The shadow session id the staged rows live under (`${realSessionId}#stg-${token}`) — opaque to the route. */ readonly stagingId: string; /** The real (destination) session id the staged rows are committed INTO. */ readonly realSessionId: string; /** Append a bounded batch of entries to the staging id, re-seq'd dense from a running counter held in the handle. * Idempotent (ON DUPLICATE KEY / ON CONFLICT DO NOTHING) — a re-streamed batch is a no-op. One txn per batch. */ appendBatch(entries: SessionTreeEntry[]): Promise; /** ATOMICALLY swap the staged rows into the real session id (ONE txn): in-txn re-classify (TOCTOU close) → purge dst * → row-key rename staging→real → session_meta LAST (the commit point). `owner` is re-stamped (§9). Throws * {@link SyncConflictError} on an unconsented fork/stale flip. Returns the committed relation. */ commit(owner: string | null, opts?: { resolution?: "overwrite-dst"; }): Promise<{ relation: SyncRelation["relation"]; }>; /** Drop the staging rows (Phase B aborted before commit) — DELETE the staging-id session_event rows. Idempotent. */ abort(): Promise; } /** A session store that can stage a streamed import (P1d-β). The 3 backends implement it; the in-memory dev store * does not → the method is optional (the route 501s when absent, parity with the other §0.5 optional seams). */ export type ImportStagingStore = { /** Begin a staged import into `realSessionId` under a shadow id `${realSessionId}#stg-${token}` (the ROUTE mints the * uuidv7 `token` and passes it — the store NEVER calls Date.now/uuidv7 itself, so the staging id is deterministic * and the route owns the lease key). Returns a {@link StagingHandle} the route streams batches into then commits. */ beginImportStaging(realSessionId: string, token: string): StagingHandle; /** Reaper — drop staging-id session_event rows with NO session_meta whose oldest row is older than the grace window * (an in-flight stream's rows stay fresh → never reaped). Mirrors `sweepOrphanBlobs`' grace discipline. */ sweepStagingSessions?(): Promise; }; /** The staging-id format: a real session id with a `#stg-${token}` suffix. The `#` is illegal in a uuidv7, so a * staging id can never collide with a real session id (the orphan invariant) and is recognizable for the reaper. */ export declare function stagingIdFor(realSessionId: string, token: string): string; /** The substring marker every staging id carries (the reaper's `LIKE '%#stg-%'` selector). */ export declare const STAGING_ID_MARKER = "#stg-"; /** Recover the real session id from a staging id (everything before the `#stg-` marker), or null if not a staging id. */ export declare function realIdOfStaging(stagingId: string): string | null; /** * Staging-row reaper grace window — a staging session's rows are GC'able only if it has NO `session_meta` (never * committed) AND its OLDEST row is older than this (an in-flight stream keeps appending fresh rows → safe). * Mirrors the file-snapshot store's `BLOB_GC_GRACE_MS` discipline (an in-flight import must never be reaped * mid-stream). * * 🔴 住在 kernel 里而不是任一个店里(2026-08-08,[ref] 二轮扫描 finding):TiDB 与 PG 两个孪生店原先**各 * 抄一份**同值常量,PG 那份的注释甚至自称「twin of the TiDB store's STAGING_GC_GRACE_MS」——明知孪生却 * 不共享。单边改值的后果是两个后端对「同一个中断的导入还能不能收」判定分家,而两边都按自己的常量静默 * 正确工作。这里是两个店**本就都 import** 的那个叶子模块(店契约纪律:适配器不许 import 自己的孪生, * 共享常量必须落在中立处),同族先例见 `checkpoint-store-sql.ts` 的 `export const TERMINAL_GRACE_MS`。 */ export declare const STAGING_GC_GRACE_MS = 3600000; /** * §7 — the relationship between a SOURCE bundle's entry log and the DESTINATION's CURRENT entry log, the gate that * decides whether an import is a safe sync or a data-losing overwrite. Five exhaustive cases: * - `fresh` — the dst has no such session (nothing to lose) → import is unconditional. * - `identical` — src and dst hold the SAME entry-id set → the conversation log is already present & equal (no-op). * - `fast_forward` — dst's entries are a STRICT subset of src (src is strictly ahead = clean append) → safe to apply. * - `stale` — src's entries are a STRICT subset of dst (src is strictly behind) → applying would LOSE the dst * entries the src never saw → a conflict (refused unless the user explicitly overwrites). * - `fork` — each side has ≥1 entry the other lacks (true divergence after a common ancestor) → a conflict. */ export type SyncRelation = { relation: "fresh"; } | { relation: "identical"; } | { relation: "fast_forward"; newEntryIds: string[]; } | { relation: "stale"; dstAheadBy: string[]; } | { relation: "fork"; commonAncestor: string | null; srcExclusive: string[]; dstExclusive: string[]; }; /** * §7 — classify how importing `srcEntries` into a destination holding `dstEntries` would relate the two logs. PURE * (no I/O); the §7 fork detector. The comparison is over the ENTRY-ID SETS, not leaf_id: * * Entry ids are copied VERBATIM across backends (fork/import re-seq only `seq`; id/parentId/payload are byte-identical * — see tidb-session-store fork/importEntries), so a set comparison of ids is the SOUND divergence test §7 demands. It * fixes v1's leaf_id-only check, which was unsound BOTH ways: * - FALSE NEGATIVE (the dangerous one): a cross-end rewind moves `LeafEntry.targetId` back onto a shared OLD entry id, * so two genuinely-forked logs report the SAME leaf_id → v1 would silently overwrite = data loss (exactly the 2c * use case). Set comparison sees each side's exclusive ids and reports `fork`. * - FALSE POSITIVE: a clean fast-forward ends on a NEW leaf id ≠ the dst's leaf → v1 flags a (non-)conflict. Set * comparison sees dst ⊊ src and reports `fast_forward` (safe). * * `commonAncestor` for a fork = the entry id present in BOTH logs that appears LAST in `srcEntries` oldest-first order * (the deepest shared appended entry = the divergence point), or `null` if the logs share no entry at all. */ export declare function classifySyncRelationship(srcEntries: SessionTreeEntry[], dstEntries: SessionTreeEntry[] | null): SyncRelation; /** * §7 — the SAME relation classifier as {@link classifySyncRelationship}, over entry-id LISTS (oldest-first) instead of * full entries. This is the canonical implementation the entries-typed wrapper delegates to. It exists in its own right * for the P1d HTTP `/sync/plan` route: the two peers are different PROCESSES (the local shell + the cloud), so the * planning peer holds only the OTHER end's entry-id list (not its `SessionTreeEntry[]`) — the relation is decidable from * the ids alone (ids are copied VERBATIM across backends, so a set comparison of ids is the sound divergence test §7 * demands; see the wrapper's doc-comment for why leaf_id was unsound). `srcIds`/`dstIds` are OLDEST-FIRST so the * fork `commonAncestor` (the deepest shared id) and the ordered exclusive/new lists are derived correctly. */ export declare function classifySyncRelationshipByIds(srcIds: string[], dstIds: string[] | null): SyncRelation; /** * §7 — a typed conflict the route maps to HTTP 409: an import that WOULD lose destination history (a `fork` or a * `stale` source) and the caller did NOT pass an explicit `{ resolution: "overwrite-dst" }`. Carries the classified * relation so the route/shell can surface the exclusive entry sets to the user for a keep-local / keep-cloud decision. */ export declare class SyncConflictError extends Error { readonly sessionId: string; readonly relation: Extract; constructor(sessionId: string, relation: Extract); } //# sourceMappingURL=session-sync-kernel.d.ts.map