@alma-harness/postgres 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +76 -0
- package/dist/index.d.ts +440 -0
- package/dist/index.js +1671 -0
- package/dist/index.js.map +1 -0
- package/package.json +48 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
import { SessionStore, Scope, Msg, LoadOpts, ToolTrafficExpiry, EpisodeStore, CopySurface, EpisodeInput, Episode, EpisodeQuery, EpisodeQueryResult, ErasureSelector, TombstoneResult, ErasureWatermarkStore, ProfileStore, ProfileReadOpts, Profile, FactObservation, ObserveResult, InvalidateResult, SpendStore, SpendKey, SpendTotals, AuditLog, AccessEvent, RoutingEvent, CostEvent, RecallEvent, ContextEvent, TurnStore, LeaseOpts, TurnLease, TurnKey, TurnClaim, CompletedTurn } from '@alma-harness/core';
|
|
2
|
+
export { AuditLog, EpisodeStore, ProfileStore, SessionStore, SpendStore, TurnStore } from '@alma-harness/core';
|
|
3
|
+
import { Pool } from 'pg';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The RLS binding, shared by every Postgres store — spec 001, spec 011.
|
|
7
|
+
*
|
|
8
|
+
* One implementation, because "the scoped transaction" is exactly the place a
|
|
9
|
+
* copy-paste divergence would silently weaken tenancy: a store that forgot the
|
|
10
|
+
* `SET LOCAL ROLE`, or set the scope after the first query, would still pass
|
|
11
|
+
* its functional tests while running with isolation off.
|
|
12
|
+
*/
|
|
13
|
+
interface ScopedStoreOptions {
|
|
14
|
+
/**
|
|
15
|
+
* Per-transaction `statement_timeout` in milliseconds (default 30s; `null`
|
|
16
|
+
* disables). A store whose queries are shaped by model-supplied input needs
|
|
17
|
+
* a ceiling that does not depend on the caller remembering one — the
|
|
18
|
+
* adversarial review drove a single search query to 25 seconds while it held
|
|
19
|
+
* a pooled connection. Set transaction-locally, so it resets on commit.
|
|
20
|
+
*/
|
|
21
|
+
statementTimeoutMs?: number | null;
|
|
22
|
+
/**
|
|
23
|
+
* Role assumed per transaction via SET LOCAL ROLE, so RLS binds even when
|
|
24
|
+
* the connection user is privileged (superusers bypass RLS; dev and CI both
|
|
25
|
+
* connect as superusers — spec 001). `null` opts out, which weakens
|
|
26
|
+
* defense-in-depth to application-level isolation only.
|
|
27
|
+
*/
|
|
28
|
+
role?: string | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type PostgresSessionStoreOptions = ScopedStoreOptions;
|
|
32
|
+
/**
|
|
33
|
+
* Reference `SessionStore` adapter (§6.6, §6.8) — spec 001.
|
|
34
|
+
*
|
|
35
|
+
* Transactional seq: `append` claims a seq range with an atomic counter
|
|
36
|
+
* upsert, so concurrent appenders serialize on the session row. Every
|
|
37
|
+
* operation runs inside a transaction scoped by transaction-local settings
|
|
38
|
+
* (`alma.org` / `alma.uid`) that the RLS policies compare against.
|
|
39
|
+
*/
|
|
40
|
+
declare class PostgresSessionStore implements SessionStore {
|
|
41
|
+
#private;
|
|
42
|
+
constructor(pool: Pool, opts?: PostgresSessionStoreOptions);
|
|
43
|
+
append(scope: Scope, sessionId: string, entries: Msg[]): Promise<void>;
|
|
44
|
+
load(scope: Scope, sessionId: string, opts?: LoadOpts): Promise<Msg[]>;
|
|
45
|
+
expireToolTraffic(scope: Scope, sessionId: string, opts: {
|
|
46
|
+
inactiveSince: string;
|
|
47
|
+
}): Promise<ToolTrafficExpiry>;
|
|
48
|
+
erase(scope: Scope, sessionId?: string): Promise<void>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Reference memory adapters (§6.7, §6.8) — spec 011. Same RLS discipline as
|
|
53
|
+
* the session store: every operation runs inside a transaction that assumes
|
|
54
|
+
* the app role and binds `alma.org`/`alma.uid`, which the policies compare
|
|
55
|
+
* against.
|
|
56
|
+
*
|
|
57
|
+
* Ranking is NOT done in SQL. The adapter filters CANDIDATES — a superset of
|
|
58
|
+
* what the tokenizer matches — and hands them to core's `rankEpisodes`, so
|
|
59
|
+
* every backend orders results identically (spec 011).
|
|
60
|
+
*/
|
|
61
|
+
type PostgresMemoryStoreOptions = ScopedStoreOptions;
|
|
62
|
+
declare class PostgresEpisodeStore implements EpisodeStore {
|
|
63
|
+
#private;
|
|
64
|
+
readonly copySurfaces: readonly CopySurface[];
|
|
65
|
+
constructor(pool: Pool, opts?: PostgresMemoryStoreOptions);
|
|
66
|
+
append(scope: Scope, input: EpisodeInput): Promise<Episode>;
|
|
67
|
+
query(scope: Scope, q: EpisodeQuery): Promise<EpisodeQueryResult>;
|
|
68
|
+
get(scope: Scope, episodeIds: readonly string[]): Promise<Episode[]>;
|
|
69
|
+
tombstone(scope: Scope, selector: ErasureSelector, rawAt: string): Promise<TombstoneResult>;
|
|
70
|
+
archive(scope: Scope, episodeIds: readonly string[]): Promise<number>;
|
|
71
|
+
}
|
|
72
|
+
declare class PostgresProfileStore implements ProfileStore {
|
|
73
|
+
#private;
|
|
74
|
+
readonly copySurfaces: readonly CopySurface[];
|
|
75
|
+
constructor(pool: Pool, opts?: PostgresMemoryStoreOptions);
|
|
76
|
+
get(scope: Scope, opts?: ProfileReadOpts): Promise<Profile>;
|
|
77
|
+
observe(scope: Scope, obs: readonly FactObservation[]): Promise<readonly ObserveResult[]>;
|
|
78
|
+
setProtected(scope: Scope, facts: readonly FactObservation[]): Promise<readonly ObserveResult[]>;
|
|
79
|
+
invalidateBySource(scope: Scope, episodeIds: readonly string[] | "all", rawAt: string): Promise<InvalidateResult>;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The scope's last erasure timestamp — the in-flight guard slice 013 compares
|
|
83
|
+
* against, so a job that started before an erasure cannot submit afterwards
|
|
84
|
+
* and re-materialize erased content.
|
|
85
|
+
*/
|
|
86
|
+
declare class PostgresErasureWatermarks implements ErasureWatermarkStore {
|
|
87
|
+
#private;
|
|
88
|
+
constructor(pool: Pool, opts?: PostgresMemoryStoreOptions);
|
|
89
|
+
get(scope: Scope): Promise<string | null>;
|
|
90
|
+
set(scope: Scope, rawAt: string): Promise<void>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Schema and migration for the session store — spec 001 — plus the RLS
|
|
95
|
+
* building blocks every Alma table shares (spec 011).
|
|
96
|
+
*
|
|
97
|
+
* Scope-stamped tables under row-level security. Migrations are idempotent
|
|
98
|
+
* (safe to run on every startup) and creating/granting the RLS role is part of
|
|
99
|
+
* them: defense-in-depth must not depend on a manual step.
|
|
100
|
+
*/
|
|
101
|
+
declare const DEFAULT_RLS_ROLE = "alma_app";
|
|
102
|
+
/**
|
|
103
|
+
* Role and table names are interpolated into DDL (Postgres cannot parameterize
|
|
104
|
+
* identifiers), so they are validated strictly — never raw.
|
|
105
|
+
*/
|
|
106
|
+
declare function assertRoleIdentifier(role: string): void;
|
|
107
|
+
/**
|
|
108
|
+
* The scope-isolation policy, identical on every Alma table — spec 001, §6.8.
|
|
109
|
+
* One generator, because a table that received a hand-copied policy with a
|
|
110
|
+
* dropped `with check` would still pass its functional tests while accepting
|
|
111
|
+
* cross-tenant writes.
|
|
112
|
+
*
|
|
113
|
+
* `keys` exists for the one org-keyed table (the spend store's tenant-day
|
|
114
|
+
* counter, which has no uid column — spec: spend-store); every scope-stamped
|
|
115
|
+
* table takes the default. Parameterized rather than forked, so RLS
|
|
116
|
+
* hardening lands on every table at once (review finding).
|
|
117
|
+
*/
|
|
118
|
+
declare function rlsPolicySql(table: string, keys?: readonly ("org" | "uid")[]): string;
|
|
119
|
+
/** Creates the RLS role if it does not exist, tolerating a concurrent race. */
|
|
120
|
+
declare function roleBootstrapSql(role: string): string;
|
|
121
|
+
/**
|
|
122
|
+
* DECISION (spec 001): SQL lives as a TS constant, not a .sql file — packages
|
|
123
|
+
* ship TypeScript source in Phase 0/1, and a migration runner would be
|
|
124
|
+
* premature for one migration.
|
|
125
|
+
*/
|
|
126
|
+
declare function sessionStoreMigrationSql(role?: string): string;
|
|
127
|
+
/** Idempotent; running it is the product's choice (typically at startup). */
|
|
128
|
+
declare function migrateSessionStore(pool: Pool, opts?: {
|
|
129
|
+
role?: string;
|
|
130
|
+
}): Promise<void>;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Schema and migration for the memory stores — spec 011.
|
|
134
|
+
*
|
|
135
|
+
* Three scope-stamped tables under the same row-level-security discipline as
|
|
136
|
+
* the session store (spec 001): policies keyed on the transaction-local
|
|
137
|
+
* `alma.org`/`alma.uid` settings, `FORCE ROW LEVEL SECURITY`, and a dedicated
|
|
138
|
+
* non-superuser role assumed per transaction. The migration is idempotent.
|
|
139
|
+
*/
|
|
140
|
+
declare const EPISODES_TABLE = "alma_memory_episodes";
|
|
141
|
+
declare const FACTS_TABLE = "alma_memory_facts";
|
|
142
|
+
declare const SCOPE_STATE_TABLE = "alma_memory_scope_state";
|
|
143
|
+
/**
|
|
144
|
+
* DECISION (spec 011): `importance` and `confidence` are `double precision`,
|
|
145
|
+
* not `real`. float4 round-trips through a text protocol with just enough
|
|
146
|
+
* digits to survive, and a 0.7 that comes back 0.699999988 would make the
|
|
147
|
+
* shared contract suite disagree with every other backend for no reason.
|
|
148
|
+
*
|
|
149
|
+
* DECISION: no pgvector column yet. Lexical ranking first (spec 010); the
|
|
150
|
+
* embedding column and its index arrive with the consumer that needs them,
|
|
151
|
+
* and will be declared as an additional COPY SURFACE so erasure reaches it.
|
|
152
|
+
*/
|
|
153
|
+
declare function memoryStoreMigrationSql(role?: string): string;
|
|
154
|
+
/** Idempotent; running it is the product's choice (typically at startup). */
|
|
155
|
+
declare function migrateMemoryStores(pool: Pool, opts?: {
|
|
156
|
+
role?: string;
|
|
157
|
+
}): Promise<void>;
|
|
158
|
+
|
|
159
|
+
type PostgresSpendStoreOptions = ScopedStoreOptions;
|
|
160
|
+
/**
|
|
161
|
+
* Reference `SpendStore` adapter — spec: spend-store. Same RLS discipline as
|
|
162
|
+
* every other store; the tenant-day counter's policy is org-only, because the
|
|
163
|
+
* counter is (see `spend-schema.ts`).
|
|
164
|
+
*
|
|
165
|
+
* `add` is one transaction over two atomic upserts, each
|
|
166
|
+
* increment-and-RETURNING, so a concurrent add serializes on the row lock and
|
|
167
|
+
* every caller observes its own distinct running total — the property the
|
|
168
|
+
* budget guard's enforcement stands on. The session row is always touched
|
|
169
|
+
* before the day row, so two adds can never order the same pair of locks
|
|
170
|
+
* differently and deadlock.
|
|
171
|
+
*
|
|
172
|
+
* The UTC day bucket is derived in TS — the same `Date.parse` reading the
|
|
173
|
+
* in-memory reference and the memory tier's `toIsoInstant` use — and reaches
|
|
174
|
+
* SQL as a finished `date` literal. Deriving it server-side read the
|
|
175
|
+
* timestamp in the SERVER's TimeZone while the reference read it in the
|
|
176
|
+
* process's, so an offset-less stamp could credit different day counters in
|
|
177
|
+
* the two stores (review finding — the same bug the episode tier fixed once
|
|
178
|
+
* already).
|
|
179
|
+
*/
|
|
180
|
+
declare class PostgresSpendStore implements SpendStore {
|
|
181
|
+
#private;
|
|
182
|
+
constructor(pool: Pool, opts?: PostgresSpendStoreOptions);
|
|
183
|
+
add(entry: SpendKey & {
|
|
184
|
+
usd: number;
|
|
185
|
+
}): Promise<SpendTotals>;
|
|
186
|
+
peek(key: SpendKey): Promise<SpendTotals>;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
type PostgresAuditLogOptions = ScopedStoreOptions;
|
|
190
|
+
/**
|
|
191
|
+
* Reference `AuditLog` sink — §6.8, spec 038. Same RLS discipline as every
|
|
192
|
+
* other store in this package: each write runs inside a transaction that
|
|
193
|
+
* assumes the app role and binds `alma.org`/`alma.uid` for the policies to
|
|
194
|
+
* compare against.
|
|
195
|
+
*
|
|
196
|
+
* WRITES SYNCHRONOUSLY, and ships no buffering wrapper. The contract permits
|
|
197
|
+
* either — "buffer internally and return synchronously to stay off the critical
|
|
198
|
+
* path, or return a promise and be awaited" — and the cost of this choice is
|
|
199
|
+
* real: a three-step turn with two tool calls emits one routing, three cost and
|
|
200
|
+
* two access events, plus recall and context, so roughly eight round trips join
|
|
201
|
+
* the turn's critical path.
|
|
202
|
+
*
|
|
203
|
+
* The alternative is worse in the way that matters. A buffered sink that loses
|
|
204
|
+
* its buffer on a crash stops writing SILENTLY, which is exactly the failure
|
|
205
|
+
* `AuditSinkError` was typed to catch (spec 027) and exactly what "where trails
|
|
206
|
+
* are written is swappable; THAT they are written is not" forbids. A product
|
|
207
|
+
* that wants the trade should own a visible wrapper, not inherit it from the
|
|
208
|
+
* reference adapter.
|
|
209
|
+
*
|
|
210
|
+
* There is no read surface here, deliberately (spec 038). §7.3's thesis is that
|
|
211
|
+
* "what exactly did the model see about this user?" is a query — but which
|
|
212
|
+
* queries matter is not yet known, and a contract shaped before its consumers
|
|
213
|
+
* exist is public API from its first release. The product queries its own
|
|
214
|
+
* tables; the rule of two decides when one has earned promotion.
|
|
215
|
+
*/
|
|
216
|
+
declare class PostgresAuditLog implements AuditLog {
|
|
217
|
+
#private;
|
|
218
|
+
constructor(pool: Pool, opts?: PostgresAuditLogOptions);
|
|
219
|
+
access(e: AccessEvent): Promise<void>;
|
|
220
|
+
routing(e: RoutingEvent): Promise<void>;
|
|
221
|
+
cost(e: CostEvent): Promise<void>;
|
|
222
|
+
recall(e: RecallEvent): Promise<void>;
|
|
223
|
+
context(e: ContextEvent): Promise<void>;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Schema and migration for the audit trails — spec 038.
|
|
228
|
+
*
|
|
229
|
+
* FIVE tables, one per event family, rather than one with a `payload jsonb`.
|
|
230
|
+
* The shapes are genuinely different, but the deciding argument is that this
|
|
231
|
+
* makes the metadata-only guarantee STRUCTURAL: `audit.ts` promises trails
|
|
232
|
+
* carry "METADATA ONLY, never content — by construction, not by reviewer
|
|
233
|
+
* vigilance", and `AccessEvent.resource` is documented as an identifier and
|
|
234
|
+
* never its content. As a `text` column named `resource` that is a constraint
|
|
235
|
+
* a reviewer can look at and a DBA can audit; as a key inside a bag, anything
|
|
236
|
+
* fits and nothing notices.
|
|
237
|
+
*
|
|
238
|
+
* It also leaves room for what comes next: cost feeds billing and must be
|
|
239
|
+
* kept, while recall and context are diagnostic and can expire early. Separate
|
|
240
|
+
* tables make that a policy per table rather than a `where family = …` smeared
|
|
241
|
+
* across every statement.
|
|
242
|
+
*/
|
|
243
|
+
declare const AUDIT_ACCESS_TABLE = "alma_audit_access";
|
|
244
|
+
declare const AUDIT_ROUTING_TABLE = "alma_audit_routing";
|
|
245
|
+
declare const AUDIT_COST_TABLE = "alma_audit_cost";
|
|
246
|
+
declare const AUDIT_RECALL_TABLE = "alma_audit_recall";
|
|
247
|
+
declare const AUDIT_CONTEXT_TABLE = "alma_audit_context";
|
|
248
|
+
/**
|
|
249
|
+
* Role the retention sweep assumes — spec 039 review.
|
|
250
|
+
*
|
|
251
|
+
* It exists because the app role deliberately cannot delete (spec 038) and the
|
|
252
|
+
* sweep therefore cannot run as it. Created and granted by the migration, for
|
|
253
|
+
* the reason `roleBootstrapSql` exists at all: defence in depth must not depend
|
|
254
|
+
* on a manual step (spec 001).
|
|
255
|
+
*/
|
|
256
|
+
declare const DEFAULT_RETENTION_ROLE = "alma_retention";
|
|
257
|
+
declare const AUDIT_TABLES: readonly ["alma_audit_access", "alma_audit_routing", "alma_audit_cost", "alma_audit_recall", "alma_audit_context"];
|
|
258
|
+
/**
|
|
259
|
+
* DECISION (spec 038): the primary key is a `uuid` defaulted by
|
|
260
|
+
* `gen_random_uuid()`, not a `bigserial`. A trail is append-only with no
|
|
261
|
+
* natural key, and a sequence would need its own `usage` grant for the
|
|
262
|
+
* non-superuser app role — one more thing to forget in a migration whose whole
|
|
263
|
+
* point is that defense-in-depth must not depend on a manual step (spec 001).
|
|
264
|
+
* `gen_random_uuid()` is core Postgres since 13; no extension.
|
|
265
|
+
*
|
|
266
|
+
* DECISION (spec 038): the grant carries `select, insert` — no `delete`, and
|
|
267
|
+
* no `update`. The spend counters' posture, for the same reason: these are
|
|
268
|
+
* content-free records kept as evidence, and an erasure that removed the proof
|
|
269
|
+
* an erasure happened is not an improvement (§10). `update` is excluded too,
|
|
270
|
+
* because an audit row that can be edited is not an audit row.
|
|
271
|
+
*
|
|
272
|
+
* DECISION (spec 038): optional array fields (`capsCrossed`, `degradedTiers`,
|
|
273
|
+
* `refused`) are `text[] not null default '{}'`. Absent and empty mean the
|
|
274
|
+
* same thing for all three — no caps crossed, no tiers degraded, nothing
|
|
275
|
+
* refused — so normalising removes a null check rather than losing a
|
|
276
|
+
* distinction.
|
|
277
|
+
*/
|
|
278
|
+
declare function auditLogMigrationSql(role?: string, retentionRole?: string): string;
|
|
279
|
+
/** Idempotent; running it is the product's choice (typically at startup). */
|
|
280
|
+
declare function migrateAuditLog(pool: Pool, opts?: {
|
|
281
|
+
role?: string;
|
|
282
|
+
retentionRole?: string;
|
|
283
|
+
}): Promise<void>;
|
|
284
|
+
/** The audit table names, as a type — one window per family (spec 039). */
|
|
285
|
+
type AuditTable = (typeof AUDIT_TABLES)[number];
|
|
286
|
+
/**
|
|
287
|
+
* Deletes audit rows older than each family's cutoff — spec 039.
|
|
288
|
+
*
|
|
289
|
+
* This resolves a consequence spec 038 created without naming: the app role
|
|
290
|
+
* was granted `select, insert` and NOT `delete`, so a scoped erasure cannot
|
|
291
|
+
* remove the proof an erasure happened — which also means the app role cannot
|
|
292
|
+
* expire audit rows.
|
|
293
|
+
*
|
|
294
|
+
* DECISION (spec 039): erasure may not delete audit rows; time-based retention
|
|
295
|
+
* may, and they are DIFFERENT ACTORS. A scoped purge runs as the app role and
|
|
296
|
+
* is blocked at the grant. Retention is maintenance: it runs as the pool's own
|
|
297
|
+
* role, crosses scopes by nature, and deliberately does NOT go through the
|
|
298
|
+
* RLS-bound `inScope` path.
|
|
299
|
+
*
|
|
300
|
+
* A plain function taking the pool rather than a method on a store, and the
|
|
301
|
+
* shape is the point — a method on a seam would suggest it participates in the
|
|
302
|
+
* scope-bound discipline, and this does not. Passing the pool makes the
|
|
303
|
+
* privilege visible at the call site.
|
|
304
|
+
*
|
|
305
|
+
* PER FAMILY, because that is what five tables bought: cost feeds billing and
|
|
306
|
+
* is kept for years while recall and context are diagnostic and can go in
|
|
307
|
+
* weeks. A single window would have made the split pointless. A table absent
|
|
308
|
+
* from `windows` is not touched.
|
|
309
|
+
*
|
|
310
|
+
* It ASSUMES {@link DEFAULT_RETENTION_ROLE} for the duration of one
|
|
311
|
+
* transaction, and that is the correction the spec 039 review forced. The first
|
|
312
|
+
* version ran the deletes straight on the pool, on the theory that it therefore
|
|
313
|
+
* "bypassed RLS". It did not: every audit table carries FORCE ROW LEVEL
|
|
314
|
+
* SECURITY, which subjects even the table owner to the predicate, so the sweep
|
|
315
|
+
* matched the scope-keyed policy with no scope bound, deleted ZERO rows, and
|
|
316
|
+
* reported success. It appeared to work only because dev and CI connect as
|
|
317
|
+
* superusers — the same elevation `scoped.ts` calls out as the reason every
|
|
318
|
+
* other store must actively assume a role rather than assume privilege.
|
|
319
|
+
*
|
|
320
|
+
* A retention mechanism that silently retains forever, in exactly the
|
|
321
|
+
* deployments careful enough not to connect as a superuser, is the failure this
|
|
322
|
+
* whole slice exists to prevent one layer up.
|
|
323
|
+
*/
|
|
324
|
+
declare function purgeAuditBefore(pool: Pool, windows: Partial<Record<AuditTable, string>>, opts?: {
|
|
325
|
+
retentionRole?: string;
|
|
326
|
+
}): Promise<Partial<Record<AuditTable, number>>>;
|
|
327
|
+
|
|
328
|
+
type PostgresTurnStoreOptions = ScopedStoreOptions;
|
|
329
|
+
/**
|
|
330
|
+
* Reference `TurnStore` adapter — spec 030. Same RLS discipline as every other
|
|
331
|
+
* store in this package.
|
|
332
|
+
*
|
|
333
|
+
* The lease is ONE conditional upsert: the row is taken only when there is no
|
|
334
|
+
* row or the existing one has expired, and Postgres serializes contenders on
|
|
335
|
+
* the primary key, so exactly one concurrent caller sees a returned row. That
|
|
336
|
+
* single statement is the whole mutual-exclusion argument — a read-then-write
|
|
337
|
+
* would let two callers both observe a free session.
|
|
338
|
+
*
|
|
339
|
+
* Waiting is POLLING with backoff, not `LISTEN`/`NOTIFY` or an advisory lock.
|
|
340
|
+
* An advisory lock is bound to the connection that took it, and a turn holds
|
|
341
|
+
* its lease across many queries from a POOL — the lease has to outlive any one
|
|
342
|
+
* connection, which is what makes it a row.
|
|
343
|
+
*/
|
|
344
|
+
declare class PostgresTurnStore implements TurnStore {
|
|
345
|
+
#private;
|
|
346
|
+
constructor(pool: Pool, opts?: PostgresTurnStoreOptions);
|
|
347
|
+
acquire(scope: Scope, sessionId: string, opts: LeaseOpts): Promise<TurnLease | null>;
|
|
348
|
+
release(scope: Scope, sessionId: string, lease: TurnLease): Promise<void>;
|
|
349
|
+
claim(key: TurnKey): Promise<TurnClaim>;
|
|
350
|
+
/**
|
|
351
|
+
* PRECONDITION: every string in `completed`, keys included, is well-formed
|
|
352
|
+
* UTF-16 — `jsonb` refuses a lone surrogate and the write fails (spec:
|
|
353
|
+
* well-formed-text).
|
|
354
|
+
*
|
|
355
|
+
* The loop repairs it on the way in: `reply` is drawn from messages
|
|
356
|
+
* `record()` passed through `toWellFormedDeep`. But that repair is
|
|
357
|
+
* BEST-EFFORT by design — its `catch` keeps the unrepaired message, because
|
|
358
|
+
* failing to repair must never cost more than not having tried — so a
|
|
359
|
+
* pathologically nested payload can still arrive malformed.
|
|
360
|
+
*
|
|
361
|
+
* Now ENFORCED here and in the in-memory reference alike, by the same guard
|
|
362
|
+
* `SessionStore.append` uses (spec 040). The adapters used to differ — that
|
|
363
|
+
* store kept the lone surrogate, this one refused the write — which is the
|
|
364
|
+
* gap spec 025's review recorded and two contracts then documented instead
|
|
365
|
+
* of closing. The check runs BEFORE the UPDATE and regardless of whether a
|
|
366
|
+
* row matches, because `$5::jsonb` is parsed either way; the in-memory
|
|
367
|
+
* reference orders it the same for that reason. The first version of this
|
|
368
|
+
* comment claimed the loop simply guaranteed it (spec 033).
|
|
369
|
+
*/
|
|
370
|
+
complete(key: TurnKey, completed: CompletedTurn): Promise<void>;
|
|
371
|
+
abandon(key: TurnKey): Promise<void>;
|
|
372
|
+
erase(scope: Scope, sessionId?: string): Promise<void>;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Schema and migration for the turn store — spec 030.
|
|
377
|
+
*
|
|
378
|
+
* Two scope-stamped tables under the standard `{org, uid}` policy, through the
|
|
379
|
+
* SAME shared generator every other Alma table uses: forking it would let
|
|
380
|
+
* future RLS hardening skip these two (the finding spec: spend-store recorded).
|
|
381
|
+
*
|
|
382
|
+
* The lease is one row per session, replaced in place; the claim is one row
|
|
383
|
+
* per idempotency key, holding the replayable turn as `jsonb`.
|
|
384
|
+
*/
|
|
385
|
+
declare const TURN_LEASES_TABLE = "alma_turn_leases";
|
|
386
|
+
declare const TURN_CLAIMS_TABLE = "alma_turn_claims";
|
|
387
|
+
/**
|
|
388
|
+
* DECISION (spec 030): `expires_at` is a `timestamptz` computed from the
|
|
389
|
+
* SERVER's `now()`, not from the process clock. Every contender for one lease
|
|
390
|
+
* must compare against one clock, and two app instances with a few seconds of
|
|
391
|
+
* drift would otherwise disagree about whether a lease is live — which is
|
|
392
|
+
* exactly the disagreement that hands the same session to two holders. This is
|
|
393
|
+
* the opposite of the spend store's day bucket, which is derived in TS
|
|
394
|
+
* precisely because it must match the reference implementation's reading of a
|
|
395
|
+
* caller-supplied timestamp; here there is no caller timestamp to match.
|
|
396
|
+
*
|
|
397
|
+
* DECISION (spec 030): `completed` is nullable, and NULL means in flight. The
|
|
398
|
+
* claim row is inserted before the turn runs and updated when it finishes, so
|
|
399
|
+
* the row's existence is the claim and its content is the result.
|
|
400
|
+
*
|
|
401
|
+
* The grant carries `delete`: unlike spend counters, these rows hold content
|
|
402
|
+
* (the reply verbatim) and §10 erasure must be able to remove them.
|
|
403
|
+
*/
|
|
404
|
+
declare function turnStoreMigrationSql(role?: string): string;
|
|
405
|
+
/** Idempotent; running it is the product's choice (typically at startup). */
|
|
406
|
+
declare function migrateTurnStore(pool: Pool, opts?: {
|
|
407
|
+
role?: string;
|
|
408
|
+
}): Promise<void>;
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Schema and migration for the spend store — spec: spend-store.
|
|
412
|
+
*
|
|
413
|
+
* Two counter tables. The session counter is scope-stamped and carries the
|
|
414
|
+
* standard `{org, uid}` policy. The tenant-day counter is deliberately
|
|
415
|
+
* ORG-KEYED — it aggregates across every uid and session of the org, which is
|
|
416
|
+
* what an operator caps or watches — so its policy compares org alone, via
|
|
417
|
+
* the SAME shared generator (`rlsPolicySql(table, ["org"])`): forking the
|
|
418
|
+
* generator would let future RLS hardening skip this one table (review
|
|
419
|
+
* finding).
|
|
420
|
+
*/
|
|
421
|
+
declare const SPEND_SESSIONS_TABLE = "alma_spend_sessions";
|
|
422
|
+
declare const SPEND_TENANT_DAYS_TABLE = "alma_spend_tenant_days";
|
|
423
|
+
/**
|
|
424
|
+
* DECISION (spec: spend-store): `usd` is `double precision`, like every
|
|
425
|
+
* fractional number in this package — the whole pricing pipeline computes in
|
|
426
|
+
* JS floats, and a NUMERIC column would round-trip as a string the driver
|
|
427
|
+
* does not sum. A single step costs fractions of a cent; the contract suite
|
|
428
|
+
* pins that nothing rounds it away.
|
|
429
|
+
*
|
|
430
|
+
* The grant carries NO delete: spend counters are retained through scoped
|
|
431
|
+
* purge (a financial record, not personal content — spec: spend-store), and
|
|
432
|
+
* the app role simply cannot remove one.
|
|
433
|
+
*/
|
|
434
|
+
declare function spendStoreMigrationSql(role?: string): string;
|
|
435
|
+
/** Idempotent; running it is the product's choice (typically at startup). */
|
|
436
|
+
declare function migrateSpendStore(pool: Pool, opts?: {
|
|
437
|
+
role?: string;
|
|
438
|
+
}): Promise<void>;
|
|
439
|
+
|
|
440
|
+
export { AUDIT_ACCESS_TABLE, AUDIT_CONTEXT_TABLE, AUDIT_COST_TABLE, AUDIT_RECALL_TABLE, AUDIT_ROUTING_TABLE, AUDIT_TABLES, type AuditTable, DEFAULT_RETENTION_ROLE, DEFAULT_RLS_ROLE, EPISODES_TABLE, FACTS_TABLE, PostgresAuditLog, type PostgresAuditLogOptions, PostgresEpisodeStore, PostgresErasureWatermarks, type PostgresMemoryStoreOptions, PostgresProfileStore, PostgresSessionStore, type PostgresSessionStoreOptions, PostgresSpendStore, type PostgresSpendStoreOptions, PostgresTurnStore, type PostgresTurnStoreOptions, SCOPE_STATE_TABLE, SPEND_SESSIONS_TABLE, SPEND_TENANT_DAYS_TABLE, type ScopedStoreOptions, TURN_CLAIMS_TABLE, TURN_LEASES_TABLE, assertRoleIdentifier, auditLogMigrationSql, memoryStoreMigrationSql, migrateAuditLog, migrateMemoryStores, migrateSessionStore, migrateSpendStore, migrateTurnStore, purgeAuditBefore, rlsPolicySql, roleBootstrapSql, sessionStoreMigrationSql, spendStoreMigrationSql, turnStoreMigrationSql };
|