/** * [ref] — the SQL `SharedMemoryStoreProvider`: the deployment's ORG-scoped shared memory libraries * (`memory_list` / `memory_read`), served out of the durable store instead of a BYOM host callback. * * ───────────────────────────────────────────────────────────────────────────────────────────────── * WHAT THIS IS (and, just as load-bearing, what it is NOT) * ───────────────────────────────────────────────────────────────────────────────────────────────── * core's seam is a document-only READ face reached exclusively through two tools. There is no model * write channel at all — write governance is the environment's, and the tool face's entire * participation in it is relaying each store's `writable` bit verbatim. This module keeps that shape: * {@link SqlSharedMemoryStore.snapshot} is the only method core ever calls; the `put*`/`delete*` half * is the DEPLOYMENT's admin plane (migrations, an import job, an operator face) and is deliberately * NOT reachable from any model-facing surface in this repo. * * 🔴 ORG PLANE ≠ THE LOCAL MEMORY CHAIN (设计 182 §13). `agent_memory_engine_*` (the [ref] * MemoryEngine) and these three tables share NOTHING: no join, no view, no shared row, no shared * scope registry. The local chain is an entry-transactional index injection with a write path; this * one is a read-only document library. Bridging them in EITHER direction would let harvested private * notes leak into a team library (or a team library into a model-writable disk), which is exactly the * boundary core's seam doc draws. If a future feature needs both, it composes them ABOVE this layer. * * ───────────────────────────────────────────────────────────────────────────────────────────────── * AUTHORIZATION (v2/F4) — server-side, injected, never inferred here * ───────────────────────────────────────────────────────────────────────────────────────────────── * `SharedMemoryRequestContext` carries the caller's `principal`. Who belongs to `org:acme` is NOT this * module's question: it takes a {@link SharedMemoryScopeAuthorizer} and serves exactly the scopes that * verdict names. The production binding folds the SAME `OrgMemoryDirectory` instance the memory-policy * face and core's admission seam already read (`src/shared-memory-scope-authorizer.ts`), so a process * has ONE answer to "who belongs to org:acme". * * The verdict is a discriminated union on purpose (same 裁定 as org-memory-admission's N3/C5): a * directory that CANNOT ANSWER must never collapse into "you belong to nothing". The first renders as * `unavailable` ("reads are refused until it is restored" — try again); the second renders as a * settled "no memory stores are connected to this session", which teaches the model to give up. Those * are opposite instructions and a fail-open collapse between them is a real defect, not a nuance. * * ───────────────────────────────────────────────────────────────────────────────────────────────── * ORDERING / PAGINATION — why the collations are part of the contract * ───────────────────────────────────────────────────────────────────────────────────────────────── * core's cursor vocabulary is "an EXCLUSIVE lower bound over document paths in plain UTF-16 code-unit * order", and its paging loop only advances while `nextCursor > windowCursor` **in JavaScript**. So the * SQL ordering must agree with JS string comparison or a listing would silently stop early. * · MySQL: the tables are `COLLATE utf8mb4_bin` ⇒ comparison is by code point. * · PG: every string column is `COLLATE "C"` ⇒ comparison is by UTF-8 byte, which for valid UTF-8 is * also code-point order. * Code-point order equals UTF-16 code-unit order for every BMP character, and differs ONLY for * supplementary-plane characters (U+10000+, whose surrogates sort before U+E000..U+FFFF in UTF-16). * Rather than leave that as an invisible "listing silently truncates" hazard, {@link assertDocumentPath} * REFUSES a non-BMP path at write time — the divergence is made impossible by construction, loudly, at * the one place a human is watching. (Store ids and org scopes are already ASCII-only by their own * shape rules, so this is the only column that needed the ruling.) * * KEYSET, never OFFSET: `WHERE store_id = ? AND path > ?` walks the PRIMARY KEY. An OFFSET pager over a * live library both re-scans the prefix on every page and skips rows when a document is inserted mid-walk. * * ───────────────────────────────────────────────────────────────────────────────────────────────── * 事务与锁 * ───────────────────────────────────────────────────────────────────────────────────────────────── * **四个**写方法(putStore / putDocument / deleteDocument / deleteStore)都是「先读注册行、再按结果写」 * 的形状,并且都用 `SELECT … FOR UPDATE` 拿同一把行锁,于是彼此串行。判据只有一处: * {@link SqlTxConn.begin} 的 `@contract txn.read-semantics` —— 加锁读在两引擎都是当前读,而「当前读」 * 的前提(TiDB 悲观模式、MySQL 会话级 REPEATABLE READ)是**连接初始化**的结构保证,不是服务器默认值。 * * 本店的**第二道防线**照旧在:`store_id` 的主键唯一性才是最终裁决者,撞键被 {@link isDupKey} 翻成一句 * 响亮的拒绝(见 putStore)—— 即便某一天读语义出岔,结果也只会是「一方被响亮拒绝」,绝不会是一次静默 * 的换属主。⚠️ 另一处不可混线的分歧:InnoDB 的加锁读对**不存在的键**取 gap/next-key 锁,TiDB 悲观事务 * **没有** gap 锁(retention-store 的「先 ON DUPLICATE 造行、再 FOR UPDATE 锁行」就是为它而写)。 * 「加锁读=当前读」两引擎同真,「gap 锁」只有 InnoDB 有 —— 两条性质不共享一个「同理」。 */ import type { Pool as MySqlPool } from "mysql2/promise"; import type { Pool as PgPool } from "pg"; import { type SharedMemoryRequestContext, type SharedMemorySnapshot, type SharedMemoryStoreProvider } from "@sema-agent/core"; import { type SqlDriver } from "./sql-driver.js"; /** Per-org plane state (`connecting` / `unavailable` / `connected`). One row per org scope. */ export declare const SHARED_MEMORY_SCOPE_TABLE = "shared_memory_scope"; /** The model-facing store registry: one row per browsable library. */ export declare const SHARED_MEMORY_STORE_TABLE = "shared_memory_store"; /** The documents. One row per (store, path). */ export declare const SHARED_MEMORY_ENTRY_TABLE = "shared_memory_entry"; /** * The plane word set — CLOSED, and consumed through an exhaustive `switch` ({@link foldPlaneState}) so * adding a word is a COMPILE error at every fold site rather than a silent default arm. */ export type SharedMemoryPlaneState = "connecting" | "unavailable" | "connected"; /** The read-authorization verdict for ONE caller. See the header's "AUTHORIZATION" section for why the * `unavailable` arm may never collapse into an empty `granted`. */ export type SharedMemoryScopeGrant = { kind: "granted"; scopes: readonly string[]; } | { kind: "unavailable"; reason: string; }; /** Injected org-membership fold. `principal` is `undefined` on a single-user deployment. */ export interface SharedMemoryScopeAuthorizer { resolve(principal: string | undefined): Promise; } /** One store registry row, as the admin plane writes it. */ export interface SharedMemoryStoreRecord { /** The model-facing handle. GLOBALLY unique in the deployment — see {@link assertStoreId}. */ storeId: string; /** The org scope that owns it (`org:acme`). */ scopeKey: string; description: string; /** Relayed verbatim to the model; core acts on it in no way whatsoever. */ writable: boolean; } /** One document, as the admin plane writes it. */ export interface SharedMemoryDocumentRecord { storeId: string; /** 🔴 属主围栏(codex 复审轮3 F1):写方必须说出**它以为的**属主 org。store id 在删除后可复用,只按 * id 寻址的话,一条迟到的重试会把上一任租户的内容注进新租户的库里。见 {@link SqlSharedMemoryStore.putDocument}。 */ scopeKey: string; path: string; content: string; /** ISO 8601, round-tripped VERBATIM (core renders only a matching `YYYY-MM-DD` prefix). */ updatedAtIso?: string; } /** Tuning knobs. Both have deployment-safe defaults; neither is model-facing. */ export interface SqlSharedMemoryStoreOptions { /** Clock injection (mtime column). Default `Date.now`. */ now?: () => number; /** Hard ceiling on ONE `list` window, whatever the caller asks for. core asks for 51; a bigger * request is an admin/HTTP caller. Default 500 — the container is bounded by declaration, not by * hope (core puts no cap on the provider's window, so the provider must state its own). */ maxListLimit?: number; } export declare function assertStoreId(storeId: string): void; export declare function assertScopeKey(scopeKey: string): void; /** * WRITE-side document-path admission. Deliberately at least as strict as core's READ-side listing gate * (`isListablePath`): a row core would silently drop is refused here where an operator can see it. The * ONE rule that is stricter on purpose is the non-BMP refusal — see the header's ORDERING section. */ export declare function assertDocumentPath(path: string): void; /** * The dual-dialect implementation. Every statement is written out in BOTH texts at its call site * ([ref] A12 判据:方言差异必须显式) — the `q(tidbSql, pgSql)` helper never builds one text. */ export declare class SqlSharedMemoryStore implements SharedMemoryStoreProvider { private readonly db; private readonly authorizer; private readonly now; private readonly maxListLimit; constructor(db: SqlDriver, authorizer: SharedMemoryScopeAuthorizer, opts?: SqlSharedMemoryStoreOptions); private q; /** * ONE registry-binding snapshot per tool call, as core's seam requires: the state, the store identity * set and each reader binding all come from this one call, so the set cannot change between "list the * stores" and "resolve one". Document state stays live — which is why a store can still vanish between * binding and read, the case {@link storeGone} names. */ snapshot(ctx: SharedMemoryRequestContext, opts: { signal?: AbortSignal; }): Promise; /** * 盘状态 + 库登记,**一条语句一个快照**。 * * 🔴 S-131(codex 对抗复审 r1 [high] 采,[ref] 的终局):这一对读要的不是新鲜度,而是「scope 行与 * store 行读齐、不撕」——一个 importer 若落在两条读之间,快照会把**旧的 connected 盘态**和**新登记的 * 库**拼在一起,于是壳拿到一个「已连接」的盘和一份不完整的库,而 reader 只复核 (store, scope) 归属、 * 从不复核盘态。 * * 此前这条保证挂在**隔离级**上(轮2 F3 起用事务,[ref] 又把两臂都钉成 REPEATABLE READ)。S-131 把 * 隔离级收进连接初始化之后,那条路在 PG 腿上走不通:PG 的 REPEATABLE READ 是快照隔离,把会话钉过去 * 会让本仓每一条 `SELECT … FOR UPDATE` 从「等锁后读当前」变成 `40001` 序列化失败(`@contract * txn.read-semantics` ② 的 PG 段),而 PG 的 `BEGIN` 是 READ COMMITTED、**每条语句各自取快照**。 * * ⇒ 改用**一条语句**读齐两张表(`UNION ALL` + `kind` 判别列)。一条语句在**任何**引擎、任何隔离级 * 下都是一个快照,于是这条保证不再依赖隔离级、不再需要事务、也不必为一次纯读去拿行锁(锁读会让 * 并发读被写者串起来,而这是会话附着的热路径)。规则少了一条:全店从此只有写方法开事务。 */ private readBinding; private readerFor; /** One keyset window. Always the PAGED shape — a large library must never be materialized per call. */ private listDocuments; /** `null` = the document does not exist. Absence is a VALUE here; only a VANISHED STORE is an error. */ private readDocument; private storeExists; /** Declare an org's plane state. Absent row ⇒ `connected` (a never-provisioned org simply has no stores). */ putScopeState(scopeKey: string, state: SharedMemoryPlaneState, message?: string): Promise; /** * Register (or re-describe) one browsable store. * * 🔴 codex 复审轮1 F1(真缺陷,已红先复现):**属主 org 不可变**。原式是一条 upsert,`scope_key` 跟着 * 被改写 —— 而文档行只按 `store_id` 编键、原地不动。于是一次撞 id 的导入(管理面手滑、重试、坏配置, * 零竞态)就把 A 的整座库连内容一起交给了 B,同时 A 失去它。所以这里改成:已存在的 id 只允许它自己的 * org 更新描述/writable 位,换 org 一律响亮拒。真要迁库,那是一次需要显式审计的搬迁(先删再建,内容 * 由搬迁方决定重导还是丢弃),不能是一条 upsert 的副作用。 * * 事务 + 行锁:属主判定与写入之间不留窗口(同一把锁 {@link deleteStore} 也拿)。并发到达的两个新建 * 会有一个撞 PK —— 那条也翻成同一句拒绝,不让驱动错误对象漏给调用方。 */ putStore(record: SharedMemoryStoreRecord): Promise; /** * Write one document. Refuses when the store is not registered — an orphan document is unreachable * through the read face, so accepting it would be a silent no-op the writer never learns about, AND a * live landmine: whoever re-registers that id later inherits it. * * 🔴 codex 复审轮1 F2/F3 两修: * · 帽在**入库**成立 —— 超 `SHARED_MEMORY_READ_CAP_BYTES` 的文档 core 横竖恒拒(从不截断),存下来 * 只是一行永远读不出来的死数据,却让每次读都要物化它。写面拒 = 那个帽在结构上为真。库里真有超大 * 文档的话,导入方分块——这是它自己就得做的事,因为模型面本来也读不到整份。 * · 存在性检查与写入**同事务同行锁**(与 {@link deleteStore} 争同一把):否则一次并发删除可以从检查 * 与插入之间穿过去,留下一行孤儿文档 —— 被删的内容在 id 复用时复活。 */ putDocument(record: SharedMemoryDocumentRecord): Promise; /** * Remove one document. Scope-fenced like {@link putDocument} (a stale delete must not reach a * successor tenant's library). Returns whether a row was actually there. * * 🔴 [ref](验真后修):此前围栏是**两条独立语句** —— `assertOwnedBy()` 先查一次登记表,然后另 * 起一条 DELETE。它自称与 `putDocument` 对齐,但 `putDocument` 的检查与写是**同事务同行锁** * (`FOR UPDATE`),而这里的两条语句之间有一道真窗:登记检查通过之后、DELETE 发出之前,另一个 org * 完成 `deleteStore` + `putStore` 的 id 复用,这条 DELETE 就落进**继任租户**的库里删掉他们的文档。 * 那正是 `putDocument` 的注里写明要挡住的那一形。现在逐字照它:同一事务、对登记行 `FOR UPDATE`、 * 拿到锁之后再删。 * * 「未登记 ⇒ 响亮抛」与「登记了但没这条文档 ⇒ 回 false」两件事仍然分家(调用方据此分支),所以 * 不能收成一条 `DELETE … WHERE EXISTS(…)`:那样 `affected === 0` 会把两种结局压成同一个读数。 */ deleteDocument(storeId: string, scopeKey: string, path: string): Promise; /** * De-register a store AND its documents, ATOMICALLY. * * 🔴 codex 复审轮1 F3(真缺陷):原式是两条独立语句。崩在中间会留下「登记着但被悄悄清空」的库,而 * 并发的 {@link putDocument} 能从检查与插入之间穿过去留下孤儿行 —— 被删的内容会在这个 id 被重新登记时 * 复活。现在两条删除同事务,并且**先拿注册行的行锁**:写面争的是同一把锁,两者因此串行。 * * 🔴 轮3 F1 同族:必须带 `scopeKey` 围栏。id 删后可复用,而一条**迟到的** deleteStore 若只按 id 寻址, * 会把继任租户刚建好的库连内容一起抹掉 —— 一次歧义的重试变成一次跨租户的破坏性操作。 */ deleteStore(storeId: string, scopeKey: string): Promise; } /** MySQL-protocol (MySQL / TiDB) binding. */ export declare class TiDBSharedMemoryStore extends SqlSharedMemoryStore { constructor(pool: MySqlPool, authorizer: SharedMemoryScopeAuthorizer, opts?: SqlSharedMemoryStoreOptions); } /** PostgreSQL binding. */ export declare class PgSharedMemoryStore extends SqlSharedMemoryStore { constructor(pool: PgPool, authorizer: SharedMemoryScopeAuthorizer, opts?: SqlSharedMemoryStoreOptions); } //# sourceMappingURL=shared-memory-store-sql.d.ts.map