@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.js
ADDED
|
@@ -0,0 +1,1671 @@
|
|
|
1
|
+
// src/session-store.ts
|
|
2
|
+
import { assertWellFormed, scopePath as scopePath2 } from "@alma-harness/core";
|
|
3
|
+
|
|
4
|
+
// src/scoped.ts
|
|
5
|
+
import { scopePath } from "@alma-harness/core";
|
|
6
|
+
|
|
7
|
+
// src/schema.ts
|
|
8
|
+
var DEFAULT_RLS_ROLE = "alma_app";
|
|
9
|
+
var IDENTIFIER = /^[a-z_][a-z0-9_]*$/;
|
|
10
|
+
function assertRoleIdentifier(role) {
|
|
11
|
+
if (!IDENTIFIER.test(role)) {
|
|
12
|
+
throw new Error(`Invalid Postgres role identifier: ${JSON.stringify(role)}`);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function rlsPolicySql(table, keys = ["org", "uid"]) {
|
|
16
|
+
assertRoleIdentifier(table);
|
|
17
|
+
const name = keys.includes("uid") ? "alma_scope_isolation" : "alma_org_isolation";
|
|
18
|
+
const predicate = keys.map((key) => `${key} = current_setting('alma.${key}', true)`).join("\n and ");
|
|
19
|
+
return `
|
|
20
|
+
alter table ${table} enable row level security;
|
|
21
|
+
alter table ${table} force row level security;
|
|
22
|
+
|
|
23
|
+
drop policy if exists ${name} on ${table};
|
|
24
|
+
create policy ${name} on ${table}
|
|
25
|
+
using (
|
|
26
|
+
${predicate}
|
|
27
|
+
)
|
|
28
|
+
with check (
|
|
29
|
+
${predicate}
|
|
30
|
+
);
|
|
31
|
+
`;
|
|
32
|
+
}
|
|
33
|
+
function roleBootstrapSql(role) {
|
|
34
|
+
assertRoleIdentifier(role);
|
|
35
|
+
return `
|
|
36
|
+
do $$
|
|
37
|
+
begin
|
|
38
|
+
if not exists (select from pg_roles where rolname = '${role}') then
|
|
39
|
+
begin
|
|
40
|
+
create role ${role} nologin;
|
|
41
|
+
exception when duplicate_object then
|
|
42
|
+
-- Another instance won the race between the existence check and the
|
|
43
|
+
-- create (pg_roles is cluster-wide and CREATE ROLE has no IF NOT
|
|
44
|
+
-- EXISTS); losing it must not roll back the rest of the migration.
|
|
45
|
+
null;
|
|
46
|
+
end;
|
|
47
|
+
end if;
|
|
48
|
+
end $$;
|
|
49
|
+
`;
|
|
50
|
+
}
|
|
51
|
+
function sessionStoreMigrationSql(role = DEFAULT_RLS_ROLE) {
|
|
52
|
+
assertRoleIdentifier(role);
|
|
53
|
+
return `
|
|
54
|
+
create table if not exists alma_sessions (
|
|
55
|
+
org text not null,
|
|
56
|
+
uid text not null,
|
|
57
|
+
session_id text not null,
|
|
58
|
+
last_seq bigint not null default 0,
|
|
59
|
+
created_at timestamptz not null default now(),
|
|
60
|
+
updated_at timestamptz not null default now(),
|
|
61
|
+
primary key (org, uid, session_id)
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
create table if not exists alma_session_entries (
|
|
65
|
+
org text not null,
|
|
66
|
+
uid text not null,
|
|
67
|
+
session_id text not null,
|
|
68
|
+
seq bigint not null,
|
|
69
|
+
msg jsonb not null,
|
|
70
|
+
created_at timestamptz not null default now(),
|
|
71
|
+
primary key (org, uid, session_id, seq),
|
|
72
|
+
foreign key (org, uid, session_id)
|
|
73
|
+
references alma_sessions (org, uid, session_id) on delete cascade
|
|
74
|
+
);
|
|
75
|
+
${rlsPolicySql("alma_sessions")}${rlsPolicySql("alma_session_entries")}
|
|
76
|
+
${roleBootstrapSql(role)}
|
|
77
|
+
grant select, insert, update, delete
|
|
78
|
+
on alma_sessions, alma_session_entries
|
|
79
|
+
to ${role};
|
|
80
|
+
`;
|
|
81
|
+
}
|
|
82
|
+
async function migrateSessionStore(pool, opts = {}) {
|
|
83
|
+
await pool.query(sessionStoreMigrationSql(opts.role));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// src/scoped.ts
|
|
87
|
+
var DEFAULT_STATEMENT_TIMEOUT_MS = 3e4;
|
|
88
|
+
function resolveRlsRole(opts) {
|
|
89
|
+
const role = opts.role === void 0 ? DEFAULT_RLS_ROLE : opts.role;
|
|
90
|
+
if (role !== null) assertRoleIdentifier(role);
|
|
91
|
+
return role;
|
|
92
|
+
}
|
|
93
|
+
function resolveStatementTimeout(opts) {
|
|
94
|
+
const ms = opts.statementTimeoutMs === void 0 ? DEFAULT_STATEMENT_TIMEOUT_MS : opts.statementTimeoutMs;
|
|
95
|
+
if (ms !== null && (!Number.isInteger(ms) || ms <= 0)) {
|
|
96
|
+
throw new Error(`statementTimeoutMs must be a positive integer or null, got ${ms}`);
|
|
97
|
+
}
|
|
98
|
+
return ms;
|
|
99
|
+
}
|
|
100
|
+
async function inScope(pool, role, scope, fn, statementTimeoutMs = DEFAULT_STATEMENT_TIMEOUT_MS) {
|
|
101
|
+
scopePath(scope);
|
|
102
|
+
const client = await pool.connect();
|
|
103
|
+
let rollbackFailed;
|
|
104
|
+
try {
|
|
105
|
+
await client.query("begin");
|
|
106
|
+
if (role !== null) await client.query(`set local role ${role}`);
|
|
107
|
+
await client.query(
|
|
108
|
+
`select set_config('alma.org', $1, true),
|
|
109
|
+
set_config('alma.uid', $2, true),
|
|
110
|
+
set_config('statement_timeout', $3, true)`,
|
|
111
|
+
[scope.org, scope.uid, statementTimeoutMs === null ? "0" : String(statementTimeoutMs)]
|
|
112
|
+
);
|
|
113
|
+
const result = await fn(client);
|
|
114
|
+
await client.query("commit");
|
|
115
|
+
return result;
|
|
116
|
+
} catch (err) {
|
|
117
|
+
await client.query("rollback").catch((rollbackErr) => {
|
|
118
|
+
rollbackFailed = rollbackErr;
|
|
119
|
+
});
|
|
120
|
+
throw err;
|
|
121
|
+
} finally {
|
|
122
|
+
client.release(rollbackFailed === void 0 ? void 0 : rollbackFailed);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// src/session-store.ts
|
|
127
|
+
var PostgresSessionStore = class {
|
|
128
|
+
#pool;
|
|
129
|
+
#role;
|
|
130
|
+
#timeoutMs;
|
|
131
|
+
constructor(pool, opts = {}) {
|
|
132
|
+
this.#pool = pool;
|
|
133
|
+
this.#role = resolveRlsRole(opts);
|
|
134
|
+
this.#timeoutMs = resolveStatementTimeout(opts);
|
|
135
|
+
}
|
|
136
|
+
async append(scope, sessionId, entries) {
|
|
137
|
+
scopePath2(scope);
|
|
138
|
+
if (entries.length === 0) return;
|
|
139
|
+
for (const [i, entry] of entries.entries()) assertWellFormed(entry, `entries[${i}]`);
|
|
140
|
+
await this.#inScope(scope, async (client) => {
|
|
141
|
+
const { rows } = await client.query(
|
|
142
|
+
`insert into alma_sessions (org, uid, session_id, last_seq)
|
|
143
|
+
values ($1, $2, $3, $4)
|
|
144
|
+
on conflict (org, uid, session_id)
|
|
145
|
+
do update set last_seq = alma_sessions.last_seq + $4, updated_at = now()
|
|
146
|
+
returning last_seq`,
|
|
147
|
+
[scope.org, scope.uid, sessionId, entries.length]
|
|
148
|
+
);
|
|
149
|
+
const lastSeq = Number(rows[0]?.last_seq);
|
|
150
|
+
const firstSeq = lastSeq - entries.length + 1;
|
|
151
|
+
const params = [scope.org, scope.uid, sessionId];
|
|
152
|
+
const tuples = entries.map((msg, i) => {
|
|
153
|
+
params.push(firstSeq + i, JSON.stringify(msg));
|
|
154
|
+
return `($1, $2, $3, $${params.length - 1}, $${params.length}::jsonb)`;
|
|
155
|
+
});
|
|
156
|
+
await client.query(
|
|
157
|
+
`insert into alma_session_entries (org, uid, session_id, seq, msg)
|
|
158
|
+
values ${tuples.join(", ")}`,
|
|
159
|
+
params
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
async load(scope, sessionId, opts) {
|
|
164
|
+
scopePath2(scope);
|
|
165
|
+
const limit = opts?.limit;
|
|
166
|
+
if (limit !== void 0 && limit <= 0) return [];
|
|
167
|
+
return this.#inScope(scope, async (client) => {
|
|
168
|
+
const { rows } = await client.query(
|
|
169
|
+
limit === void 0 ? `select msg from alma_session_entries
|
|
170
|
+
where org = $1 and uid = $2 and session_id = $3
|
|
171
|
+
order by seq asc` : `select msg from alma_session_entries
|
|
172
|
+
where org = $1 and uid = $2 and session_id = $3
|
|
173
|
+
order by seq desc limit $4`,
|
|
174
|
+
limit === void 0 ? [scope.org, scope.uid, sessionId] : [scope.org, scope.uid, sessionId, limit]
|
|
175
|
+
);
|
|
176
|
+
const msgs = rows.map((r) => r.msg);
|
|
177
|
+
return limit === void 0 ? msgs : msgs.reverse();
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
async expireToolTraffic(scope, sessionId, opts) {
|
|
181
|
+
scopePath2(scope);
|
|
182
|
+
const cutoff = instant(opts.inactiveSince);
|
|
183
|
+
return this.#inScope(scope, async (client) => {
|
|
184
|
+
await client.query(
|
|
185
|
+
`select last_seq from alma_sessions
|
|
186
|
+
where org = $1 and uid = $2 and session_id = $3
|
|
187
|
+
for update`,
|
|
188
|
+
[scope.org, scope.uid, sessionId]
|
|
189
|
+
);
|
|
190
|
+
const { rows } = await client.query(
|
|
191
|
+
`select seq, msg from alma_session_entries
|
|
192
|
+
where org = $1 and uid = $2 and session_id = $3
|
|
193
|
+
order by seq asc`,
|
|
194
|
+
[scope.org, scope.uid, sessionId]
|
|
195
|
+
);
|
|
196
|
+
const active = rows.some((r) => {
|
|
197
|
+
const at = r.msg.meta?.at;
|
|
198
|
+
if (at === void 0) return true;
|
|
199
|
+
const ms = Date.parse(at);
|
|
200
|
+
return Number.isNaN(ms) || ms > cutoff;
|
|
201
|
+
});
|
|
202
|
+
if (active) return { blocks: 0, messages: 0, expired: false };
|
|
203
|
+
let blocks = 0;
|
|
204
|
+
const rewrites = [];
|
|
205
|
+
const removals = [];
|
|
206
|
+
for (const row of rows) {
|
|
207
|
+
const survivors = row.msg.blocks.filter(
|
|
208
|
+
(b) => b.type !== "tool_call" && b.type !== "tool_result"
|
|
209
|
+
);
|
|
210
|
+
if (survivors.length === row.msg.blocks.length) continue;
|
|
211
|
+
blocks += row.msg.blocks.length - survivors.length;
|
|
212
|
+
if (survivors.length === 0) removals.push(row.seq);
|
|
213
|
+
else rewrites.push({ seq: row.seq, msg: { ...row.msg, blocks: survivors } });
|
|
214
|
+
}
|
|
215
|
+
for (const r of rewrites) {
|
|
216
|
+
await client.query(
|
|
217
|
+
`update alma_session_entries set msg = $5::jsonb
|
|
218
|
+
where org = $1 and uid = $2 and session_id = $3 and seq = $4`,
|
|
219
|
+
[scope.org, scope.uid, sessionId, r.seq, JSON.stringify(r.msg)]
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
if (removals.length > 0) {
|
|
223
|
+
await client.query(
|
|
224
|
+
`delete from alma_session_entries
|
|
225
|
+
where org = $1 and uid = $2 and session_id = $3 and seq = any($4::bigint[])`,
|
|
226
|
+
[scope.org, scope.uid, sessionId, removals]
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
return { blocks, messages: removals.length, expired: true };
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
async erase(scope, sessionId) {
|
|
233
|
+
await this.#inScope(scope, async (client) => {
|
|
234
|
+
if (sessionId === void 0) {
|
|
235
|
+
await client.query(`delete from alma_sessions where org = $1 and uid = $2`, [
|
|
236
|
+
scope.org,
|
|
237
|
+
scope.uid
|
|
238
|
+
]);
|
|
239
|
+
} else {
|
|
240
|
+
await client.query(
|
|
241
|
+
`delete from alma_sessions where org = $1 and uid = $2 and session_id = $3`,
|
|
242
|
+
[scope.org, scope.uid, sessionId]
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
/** Shared RLS binding — see `scoped.ts`. */
|
|
248
|
+
async #inScope(scope, fn) {
|
|
249
|
+
return inScope(this.#pool, this.#role, scope, fn, this.#timeoutMs);
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
function instant(at) {
|
|
253
|
+
const ms = Date.parse(at);
|
|
254
|
+
if (Number.isNaN(ms)) throw new Error(`invalid ISO 8601 timestamp: ${JSON.stringify(at)}`);
|
|
255
|
+
return ms;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// src/memory-stores.ts
|
|
259
|
+
import {
|
|
260
|
+
scopePath as scopePath3
|
|
261
|
+
} from "@alma-harness/core";
|
|
262
|
+
import {
|
|
263
|
+
applyMemoryBudget,
|
|
264
|
+
assertBatchSize,
|
|
265
|
+
assertEpisodeInput,
|
|
266
|
+
assertEpisodeQuery,
|
|
267
|
+
assertErasureSelector,
|
|
268
|
+
assertFactObservation,
|
|
269
|
+
compareFactsForRecall,
|
|
270
|
+
decideObservation,
|
|
271
|
+
DEFAULT_CONFIDENCE,
|
|
272
|
+
DEFAULT_IMPORTANCE,
|
|
273
|
+
deriveEpisodeId,
|
|
274
|
+
deriveFactId,
|
|
275
|
+
isProtectedProfileKey,
|
|
276
|
+
mergeRefresh,
|
|
277
|
+
MEMORY_LIMITS,
|
|
278
|
+
rankEpisodes,
|
|
279
|
+
StaleWriteError,
|
|
280
|
+
toIsoInstant,
|
|
281
|
+
tokenizeMemoryText
|
|
282
|
+
} from "@alma-harness/memory";
|
|
283
|
+
|
|
284
|
+
// src/memory-schema.ts
|
|
285
|
+
var EPISODES_TABLE = "alma_memory_episodes";
|
|
286
|
+
var FACTS_TABLE = "alma_memory_facts";
|
|
287
|
+
var SCOPE_STATE_TABLE = "alma_memory_scope_state";
|
|
288
|
+
function memoryStoreMigrationSql(role = DEFAULT_RLS_ROLE) {
|
|
289
|
+
assertRoleIdentifier(role);
|
|
290
|
+
return `
|
|
291
|
+
create table if not exists ${EPISODES_TABLE} (
|
|
292
|
+
org text not null,
|
|
293
|
+
uid text not null,
|
|
294
|
+
id text not null,
|
|
295
|
+
at timestamptz not null,
|
|
296
|
+
kind text not null,
|
|
297
|
+
summary text not null,
|
|
298
|
+
-- Case-folded copy of summary, folded in the ADAPTER (JS toLowerCase), so
|
|
299
|
+
-- the candidate filter never depends on the database's ctype: under lc_ctype
|
|
300
|
+
-- of C, ilike folds ASCII only, and 'CAF\xC9' silently stopped matching 'caf\xE9'
|
|
301
|
+
-- while the in-memory reference matched it (the 011\u2013013 review). Blanked by the
|
|
302
|
+
-- tombstone exactly as summary is \u2014 it is the same content.
|
|
303
|
+
summary_fold text not null default '',
|
|
304
|
+
importance double precision not null,
|
|
305
|
+
state text not null default 'active',
|
|
306
|
+
source_session_id text,
|
|
307
|
+
source_turn_id text,
|
|
308
|
+
erased_at timestamptz,
|
|
309
|
+
created_at timestamptz not null default now(),
|
|
310
|
+
updated_at timestamptz not null default now(),
|
|
311
|
+
primary key (org, uid, id),
|
|
312
|
+
constraint alma_memory_episodes_state_check
|
|
313
|
+
check (state in ('active', 'archived', 'tombstoned'))
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
-- Migration for tables created before summary_fold existed. The SQL lower()
|
|
317
|
+
-- backfill is the best the database can do (exact under a folding ctype,
|
|
318
|
+
-- ASCII-only under C); every adapter write from then on stores the JS fold.
|
|
319
|
+
alter table ${EPISODES_TABLE} add column if not exists summary_fold text not null default '';
|
|
320
|
+
update ${EPISODES_TABLE} set summary_fold = lower(summary)
|
|
321
|
+
where summary_fold = '' and summary <> '';
|
|
322
|
+
|
|
323
|
+
create index if not exists alma_memory_episodes_recent
|
|
324
|
+
on ${EPISODES_TABLE} (org, uid, state, at desc);
|
|
325
|
+
|
|
326
|
+
create index if not exists alma_memory_episodes_session
|
|
327
|
+
on ${EPISODES_TABLE} (org, uid, source_session_id);
|
|
328
|
+
|
|
329
|
+
create table if not exists ${FACTS_TABLE} (
|
|
330
|
+
org text not null,
|
|
331
|
+
uid text not null,
|
|
332
|
+
id text not null,
|
|
333
|
+
key text not null,
|
|
334
|
+
value text not null,
|
|
335
|
+
confidence double precision not null,
|
|
336
|
+
source_episode_ids text[] not null default '{}',
|
|
337
|
+
observed_at timestamptz not null,
|
|
338
|
+
last_seen_at timestamptz not null,
|
|
339
|
+
superseded_at timestamptz,
|
|
340
|
+
superseded_by text,
|
|
341
|
+
invalidated_at timestamptz,
|
|
342
|
+
ttl_days integer,
|
|
343
|
+
primary key (org, uid, id)
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
-- "At most one CURRENT version per key" is a database invariant, not an
|
|
347
|
+
-- application hope: the confidence-gated supersession rule reads the current
|
|
348
|
+
-- version and writes a new one, and a lost race must fail loudly rather than
|
|
349
|
+
-- leave a profile with two contradictory current facts.
|
|
350
|
+
create unique index if not exists alma_memory_facts_current
|
|
351
|
+
on ${FACTS_TABLE} (org, uid, key)
|
|
352
|
+
where superseded_at is null and invalidated_at is null;
|
|
353
|
+
|
|
354
|
+
-- Derived invalidation walks provenance on every erasure.
|
|
355
|
+
create index if not exists alma_memory_facts_sources
|
|
356
|
+
on ${FACTS_TABLE} using gin (source_episode_ids);
|
|
357
|
+
|
|
358
|
+
create table if not exists ${SCOPE_STATE_TABLE} (
|
|
359
|
+
org text not null,
|
|
360
|
+
uid text not null,
|
|
361
|
+
erasure_watermark timestamptz,
|
|
362
|
+
primary key (org, uid)
|
|
363
|
+
);
|
|
364
|
+
${rlsPolicySql(EPISODES_TABLE)}${rlsPolicySql(FACTS_TABLE)}${rlsPolicySql(SCOPE_STATE_TABLE)}
|
|
365
|
+
${roleBootstrapSql(role)}
|
|
366
|
+
grant select, insert, update, delete
|
|
367
|
+
on ${EPISODES_TABLE}, ${FACTS_TABLE}, ${SCOPE_STATE_TABLE}
|
|
368
|
+
to ${role};
|
|
369
|
+
`;
|
|
370
|
+
}
|
|
371
|
+
async function migrateMemoryStores(pool, opts = {}) {
|
|
372
|
+
await pool.query(memoryStoreMigrationSql(opts.role));
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// src/memory-stores.ts
|
|
376
|
+
var CANDIDATE_WINDOW = 200;
|
|
377
|
+
var EPISODE_COLUMNS = `id, at, kind, summary, importance, state,
|
|
378
|
+
source_session_id, source_turn_id, erased_at`;
|
|
379
|
+
var FACT_COLUMNS = `id, key, value, confidence, source_episode_ids,
|
|
380
|
+
observed_at, last_seen_at, superseded_at, superseded_by, invalidated_at, ttl_days`;
|
|
381
|
+
function toEpisode(row) {
|
|
382
|
+
const episode = {
|
|
383
|
+
id: row.id,
|
|
384
|
+
at: row.at.toISOString(),
|
|
385
|
+
kind: row.kind,
|
|
386
|
+
summary: row.summary,
|
|
387
|
+
importance: row.importance,
|
|
388
|
+
state: row.state
|
|
389
|
+
};
|
|
390
|
+
if (row.source_session_id !== null || row.source_turn_id !== null) {
|
|
391
|
+
const source = {};
|
|
392
|
+
if (row.source_session_id !== null) source.sessionId = row.source_session_id;
|
|
393
|
+
if (row.source_turn_id !== null) source.turnId = row.source_turn_id;
|
|
394
|
+
episode.source = source;
|
|
395
|
+
}
|
|
396
|
+
if (row.erased_at !== null) episode.erasedAt = row.erased_at.toISOString();
|
|
397
|
+
return episode;
|
|
398
|
+
}
|
|
399
|
+
function toFact(row) {
|
|
400
|
+
const fact = {
|
|
401
|
+
id: row.id,
|
|
402
|
+
key: row.key,
|
|
403
|
+
value: row.value,
|
|
404
|
+
confidence: row.confidence,
|
|
405
|
+
sourceEpisodeIds: row.source_episode_ids,
|
|
406
|
+
observedAt: row.observed_at.toISOString(),
|
|
407
|
+
lastSeenAt: row.last_seen_at.toISOString()
|
|
408
|
+
};
|
|
409
|
+
if (row.superseded_at !== null) fact.supersededAt = row.superseded_at.toISOString();
|
|
410
|
+
if (row.superseded_by !== null) fact.supersededBy = row.superseded_by;
|
|
411
|
+
if (row.invalidated_at !== null) fact.invalidatedAt = row.invalidated_at.toISOString();
|
|
412
|
+
if (row.ttl_days !== null) fact.ttlDays = row.ttl_days;
|
|
413
|
+
return fact;
|
|
414
|
+
}
|
|
415
|
+
var PostgresEpisodeStore = class {
|
|
416
|
+
copySurfaces = [{ name: EPISODES_TABLE, kind: "primary" }];
|
|
417
|
+
#pool;
|
|
418
|
+
#role;
|
|
419
|
+
#timeoutMs;
|
|
420
|
+
constructor(pool, opts = {}) {
|
|
421
|
+
this.#pool = pool;
|
|
422
|
+
this.#role = resolveRlsRole(opts);
|
|
423
|
+
this.#timeoutMs = resolveStatementTimeout(opts);
|
|
424
|
+
}
|
|
425
|
+
async append(scope, input) {
|
|
426
|
+
assertEpisodeInput(input);
|
|
427
|
+
const id = deriveEpisodeId(scope, input);
|
|
428
|
+
const at = toIsoInstant(input.at ?? (/* @__PURE__ */ new Date()).toISOString());
|
|
429
|
+
return this.#inScope(scope, async (client) => {
|
|
430
|
+
await client.query(scopeLockSql("shared"), [scopeLockKey(scope)]);
|
|
431
|
+
const { rows: mark } = await client.query(
|
|
432
|
+
`select erasure_watermark from ${SCOPE_STATE_TABLE} where org = $1 and uid = $2`,
|
|
433
|
+
[scope.org, scope.uid]
|
|
434
|
+
);
|
|
435
|
+
const watermark = mark[0]?.erasure_watermark?.toISOString() ?? null;
|
|
436
|
+
if (watermark !== null && at < watermark) {
|
|
437
|
+
throw new StaleWriteError(
|
|
438
|
+
`episode stamped ${at} precedes this scope's erasure at ${watermark}`
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
await client.query(
|
|
442
|
+
`insert into ${EPISODES_TABLE}
|
|
443
|
+
(org, uid, id, at, kind, summary, summary_fold, importance, source_session_id, source_turn_id)
|
|
444
|
+
values ($1, $2, $3, $4::timestamptz, $5, $6, $7, $8, $9, $10)
|
|
445
|
+
on conflict (org, uid, id) do update
|
|
446
|
+
set at = excluded.at,
|
|
447
|
+
kind = excluded.kind,
|
|
448
|
+
summary = excluded.summary,
|
|
449
|
+
summary_fold = excluded.summary_fold,
|
|
450
|
+
importance = excluded.importance,
|
|
451
|
+
source_session_id = excluded.source_session_id,
|
|
452
|
+
source_turn_id = excluded.source_turn_id,
|
|
453
|
+
updated_at = now()
|
|
454
|
+
where ${EPISODES_TABLE}.state <> 'tombstoned'`,
|
|
455
|
+
[
|
|
456
|
+
scope.org,
|
|
457
|
+
scope.uid,
|
|
458
|
+
id,
|
|
459
|
+
at,
|
|
460
|
+
input.kind,
|
|
461
|
+
input.summary,
|
|
462
|
+
// Folded HERE, in JS, with the same toLowerCase the tokenizer uses —
|
|
463
|
+
// never in SQL, where the fold depends on the database's ctype (see
|
|
464
|
+
// the schema comment on summary_fold).
|
|
465
|
+
input.summary.toLowerCase(),
|
|
466
|
+
input.importance ?? DEFAULT_IMPORTANCE,
|
|
467
|
+
input.source?.sessionId ?? null,
|
|
468
|
+
input.source?.turnId ?? null
|
|
469
|
+
]
|
|
470
|
+
);
|
|
471
|
+
const { rows } = await client.query(
|
|
472
|
+
`select ${EPISODE_COLUMNS} from ${EPISODES_TABLE}
|
|
473
|
+
where org = $1 and uid = $2 and id = $3`,
|
|
474
|
+
[scope.org, scope.uid, id]
|
|
475
|
+
);
|
|
476
|
+
const row = rows[0];
|
|
477
|
+
if (!row) throw new Error(`episode ${id} vanished between write and read`);
|
|
478
|
+
return toEpisode(row);
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
async query(scope, q) {
|
|
482
|
+
scopePath3(scope);
|
|
483
|
+
assertEpisodeQuery(q);
|
|
484
|
+
if (q.limit !== void 0 && q.limit <= 0) return { episodes: [], truncated: false };
|
|
485
|
+
const states = q.includeArchived === true ? ["active", "archived"] : ["active"];
|
|
486
|
+
const terms = q.text === void 0 ? /* @__PURE__ */ new Set() : tokenizeMemoryText(q.text);
|
|
487
|
+
if (q.text !== void 0 && terms.size === 0) return { episodes: [], truncated: false };
|
|
488
|
+
const patterns = terms.size === 0 ? null : [...terms].map((t) => `%${t}%`);
|
|
489
|
+
const window = Math.max(CANDIDATE_WINDOW, 4 * (q.limit ?? 0));
|
|
490
|
+
return this.#inScope(scope, async (client) => {
|
|
491
|
+
const { rows } = await client.query(
|
|
492
|
+
`select ${EPISODE_COLUMNS} from ${EPISODES_TABLE}
|
|
493
|
+
where org = $1 and uid = $2
|
|
494
|
+
and state = any($3::text[])
|
|
495
|
+
and ($4::text[] is null or kind = any($4::text[]))
|
|
496
|
+
and ($5::timestamptz is null or at >= $5::timestamptz)
|
|
497
|
+
and ($6::timestamptz is null or at <= $6::timestamptz)
|
|
498
|
+
and ($7::text[] is null or summary_fold like any($7::text[]))
|
|
499
|
+
order by at desc
|
|
500
|
+
limit $8`,
|
|
501
|
+
[
|
|
502
|
+
scope.org,
|
|
503
|
+
scope.uid,
|
|
504
|
+
states,
|
|
505
|
+
q.kinds === void 0 ? null : [...q.kinds],
|
|
506
|
+
// Normalized like every other timestamp in the tier: raw bounds are
|
|
507
|
+
// read in the SERVER's timezone while the ranker reads them in the
|
|
508
|
+
// process's, so an offsetless bound filtered differently on each.
|
|
509
|
+
q.since === void 0 ? null : toIsoInstant(q.since),
|
|
510
|
+
q.until === void 0 ? null : toIsoInstant(q.until),
|
|
511
|
+
patterns,
|
|
512
|
+
window + 1
|
|
513
|
+
// one extra row is how we learn the window was saturated
|
|
514
|
+
]
|
|
515
|
+
);
|
|
516
|
+
const clipped = rows.length > window;
|
|
517
|
+
const ranked = rankEpisodes(
|
|
518
|
+
rows.slice(0, window).map(toEpisode),
|
|
519
|
+
q,
|
|
520
|
+
(/* @__PURE__ */ new Date()).toISOString()
|
|
521
|
+
);
|
|
522
|
+
const limited = q.limit === void 0 ? ranked : ranked.slice(0, q.limit);
|
|
523
|
+
const { kept, truncated } = applyMemoryBudget(limited, q.budget, (ep) => ep.summary.length);
|
|
524
|
+
return {
|
|
525
|
+
episodes: kept,
|
|
526
|
+
truncated: truncated || limited.length < ranked.length || clipped
|
|
527
|
+
};
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
async get(scope, episodeIds) {
|
|
531
|
+
scopePath3(scope);
|
|
532
|
+
assertBatchSize(episodeIds.length, "episodeIds", MEMORY_LIMITS.idsPerCall);
|
|
533
|
+
if (episodeIds.length === 0) return [];
|
|
534
|
+
return this.#inScope(scope, async (client) => {
|
|
535
|
+
const { rows } = await client.query(
|
|
536
|
+
`select ${EPISODE_COLUMNS} from ${EPISODES_TABLE}
|
|
537
|
+
where org = $1 and uid = $2 and id = any($3::text[])`,
|
|
538
|
+
[scope.org, scope.uid, [...episodeIds]]
|
|
539
|
+
);
|
|
540
|
+
const byId = new Map(rows.map((r) => [r.id, toEpisode(r)]));
|
|
541
|
+
return episodeIds.flatMap((id) => {
|
|
542
|
+
const ep = byId.get(id);
|
|
543
|
+
return ep ? [ep] : [];
|
|
544
|
+
});
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
async tombstone(scope, selector, rawAt) {
|
|
548
|
+
const at = toIsoInstant(rawAt);
|
|
549
|
+
assertErasureSelector(selector);
|
|
550
|
+
const [predicate, param] = selectorPredicate(selector);
|
|
551
|
+
return this.#inScope(scope, async (client) => {
|
|
552
|
+
await client.query(scopeLockSql("exclusive"), [scopeLockKey(scope)]);
|
|
553
|
+
const { rows } = await client.query(
|
|
554
|
+
`with matched as (
|
|
555
|
+
select id from ${EPISODES_TABLE}
|
|
556
|
+
where org = $1 and uid = $2 and ${predicate}
|
|
557
|
+
), blanked as (
|
|
558
|
+
update ${EPISODES_TABLE}
|
|
559
|
+
set kind = '', summary = '', summary_fold = '', state = 'tombstoned',
|
|
560
|
+
erased_at = $3::timestamptz, updated_at = now()
|
|
561
|
+
where org = $1 and uid = $2 and state <> 'tombstoned'
|
|
562
|
+
and id in (select id from matched)
|
|
563
|
+
returning id
|
|
564
|
+
)
|
|
565
|
+
select m.id, (b.id is not null) as written
|
|
566
|
+
from matched m left join blanked b on b.id = m.id`,
|
|
567
|
+
param === null ? [scope.org, scope.uid, at] : [scope.org, scope.uid, at, param]
|
|
568
|
+
);
|
|
569
|
+
return {
|
|
570
|
+
episodeIds: rows.map((r) => r.id),
|
|
571
|
+
written: rows.filter((r) => r.written).length,
|
|
572
|
+
surfaces: [EPISODES_TABLE]
|
|
573
|
+
};
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
async archive(scope, episodeIds) {
|
|
577
|
+
scopePath3(scope);
|
|
578
|
+
assertBatchSize(episodeIds.length, "episodeIds", MEMORY_LIMITS.idsPerCall);
|
|
579
|
+
if (episodeIds.length === 0) return 0;
|
|
580
|
+
return this.#inScope(scope, async (client) => {
|
|
581
|
+
const { rowCount } = await client.query(
|
|
582
|
+
`update ${EPISODES_TABLE} set state = 'archived', updated_at = now()
|
|
583
|
+
where org = $1 and uid = $2 and state = 'active' and id = any($3::text[])`,
|
|
584
|
+
[scope.org, scope.uid, [...episodeIds]]
|
|
585
|
+
);
|
|
586
|
+
return rowCount ?? 0;
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
async #inScope(scope, fn) {
|
|
590
|
+
return inScope(this.#pool, this.#role, scope, fn, this.#timeoutMs);
|
|
591
|
+
}
|
|
592
|
+
};
|
|
593
|
+
function selectorPredicate(selector) {
|
|
594
|
+
switch (selector.kind) {
|
|
595
|
+
case "all":
|
|
596
|
+
return ["true", null];
|
|
597
|
+
case "episodes":
|
|
598
|
+
return ["id = any($4::text[])", [...selector.episodeIds]];
|
|
599
|
+
case "sessions":
|
|
600
|
+
return ["source_session_id = any($4::text[])", [...selector.sessionIds]];
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
var EPOCH = "1970-01-01T00:00:00.000Z";
|
|
604
|
+
function scopeLockSql(mode) {
|
|
605
|
+
const fn = mode === "shared" ? "pg_advisory_xact_lock_shared" : "pg_advisory_xact_lock";
|
|
606
|
+
return `select ${fn}(hashtextextended($1::text, 0::bigint))`;
|
|
607
|
+
}
|
|
608
|
+
var scopeLockKey = (scope) => `${scope.org}/${scope.uid}`;
|
|
609
|
+
var PostgresProfileStore = class {
|
|
610
|
+
copySurfaces = [{ name: FACTS_TABLE, kind: "primary" }];
|
|
611
|
+
#pool;
|
|
612
|
+
#role;
|
|
613
|
+
#timeoutMs;
|
|
614
|
+
constructor(pool, opts = {}) {
|
|
615
|
+
this.#pool = pool;
|
|
616
|
+
this.#role = resolveRlsRole(opts);
|
|
617
|
+
this.#timeoutMs = resolveStatementTimeout(opts);
|
|
618
|
+
}
|
|
619
|
+
async get(scope, opts = {}) {
|
|
620
|
+
return this.#inScope(scope, async (client) => {
|
|
621
|
+
const { rows } = await client.query(
|
|
622
|
+
`with stamp as (
|
|
623
|
+
select max(last_seen_at) as updated_at from ${FACTS_TABLE}
|
|
624
|
+
where org = $1 and uid = $2 and invalidated_at is null
|
|
625
|
+
)
|
|
626
|
+
select updated_at, ${FACT_COLUMNS}
|
|
627
|
+
from stamp
|
|
628
|
+
left join ${FACTS_TABLE}
|
|
629
|
+
on ${FACTS_TABLE}.org = $1 and ${FACTS_TABLE}.uid = $2
|
|
630
|
+
and ($3::boolean or (${FACTS_TABLE}.superseded_at is null
|
|
631
|
+
and ${FACTS_TABLE}.invalidated_at is null))`,
|
|
632
|
+
[scope.org, scope.uid, opts.includeHistory === true]
|
|
633
|
+
);
|
|
634
|
+
const facts = rows.filter((row) => row.id !== null).map((row) => toFact(row)).sort(compareFactsForRecall);
|
|
635
|
+
const { kept, truncated } = applyMemoryBudget(
|
|
636
|
+
facts,
|
|
637
|
+
opts.budget,
|
|
638
|
+
(f) => f.key.length + f.value.length
|
|
639
|
+
);
|
|
640
|
+
return {
|
|
641
|
+
facts: kept,
|
|
642
|
+
updatedAt: rows[0]?.updated_at?.toISOString() ?? EPOCH,
|
|
643
|
+
truncated
|
|
644
|
+
};
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
async observe(scope, obs) {
|
|
648
|
+
return this.#write(scope, obs, false);
|
|
649
|
+
}
|
|
650
|
+
async setProtected(scope, facts) {
|
|
651
|
+
return this.#write(scope, facts, true);
|
|
652
|
+
}
|
|
653
|
+
async invalidateBySource(scope, episodeIds, rawAt) {
|
|
654
|
+
const at = toIsoInstant(rawAt);
|
|
655
|
+
return this.#inScope(scope, async (client) => {
|
|
656
|
+
await client.query(scopeLockSql("exclusive"), [scopeLockKey(scope)]);
|
|
657
|
+
const { rowCount } = await client.query(
|
|
658
|
+
`update ${FACTS_TABLE} set invalidated_at = $3::timestamptz, key = '', value = ''
|
|
659
|
+
where org = $1 and uid = $2 and invalidated_at is null
|
|
660
|
+
and ($4::text[] is null or source_episode_ids && $4::text[])`,
|
|
661
|
+
[scope.org, scope.uid, at, episodeIds === "all" ? null : [...episodeIds]]
|
|
662
|
+
);
|
|
663
|
+
return { invalidated: rowCount ?? 0, surfaces: [FACTS_TABLE] };
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
async #write(scope, obs, trusted) {
|
|
667
|
+
scopePath3(scope);
|
|
668
|
+
assertBatchSize(obs.length, "observations", MEMORY_LIMITS.batchItems);
|
|
669
|
+
for (const o of obs) assertFactObservation(o);
|
|
670
|
+
if (obs.length === 0) return [];
|
|
671
|
+
const lockKeys = [
|
|
672
|
+
...new Set(
|
|
673
|
+
obs.filter((o) => trusted || !isProtectedProfileKey(o.key)).map((o) => o.key)
|
|
674
|
+
)
|
|
675
|
+
].sort();
|
|
676
|
+
const batchAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
677
|
+
const stamps = obs.map((o) => toIsoInstant(o.at ?? batchAt));
|
|
678
|
+
const candidateIds = obs.map(
|
|
679
|
+
(o, i) => deriveFactId(scope, { key: o.key, value: o.value, observedAt: stamps[i] })
|
|
680
|
+
);
|
|
681
|
+
return this.#inScope(scope, async (client) => {
|
|
682
|
+
await client.query(scopeLockSql("shared"), [scopeLockKey(scope)]);
|
|
683
|
+
if (lockKeys.length > 0) {
|
|
684
|
+
await client.query(
|
|
685
|
+
`select pg_advisory_xact_lock(hashtextextended(k, 0::bigint))
|
|
686
|
+
from unnest($1::text[]) as k`,
|
|
687
|
+
[lockKeys.map((key) => `${scope.org}/${scope.uid}/${key}`)]
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
const state = await this.#loadWriteState(client, scope, obs, lockKeys, candidateIds);
|
|
691
|
+
const { results, writes } = planObservations(obs, trusted, stamps, candidateIds, state);
|
|
692
|
+
const lost = await applyWrites(client, scope, writes);
|
|
693
|
+
for (const index of lost) {
|
|
694
|
+
results[index] = {
|
|
695
|
+
key: obs[index].key,
|
|
696
|
+
outcome: "stale",
|
|
697
|
+
detail: "the version this observation refreshed was closed concurrently"
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
return results;
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* Everything the batch needs to decide, read in a FIXED number of queries.
|
|
705
|
+
*
|
|
706
|
+
* This is the half of the N+1 that was pure reads: the watermark once, then
|
|
707
|
+
* a provenance check, a current-version read, and a replay check PER
|
|
708
|
+
* OBSERVATION — up to three hundred round trips for a full batch, every one
|
|
709
|
+
* of them holding the scope's shared lock and all its key locks (the
|
|
710
|
+
* 011-013 review).
|
|
711
|
+
*/
|
|
712
|
+
async #loadWriteState(client, scope, obs, keys, candidateIds) {
|
|
713
|
+
const { rows: mark } = await client.query(
|
|
714
|
+
`select erasure_watermark from ${SCOPE_STATE_TABLE} where org = $1 and uid = $2`,
|
|
715
|
+
[scope.org, scope.uid]
|
|
716
|
+
);
|
|
717
|
+
const watermark = mark[0]?.erasure_watermark?.toISOString() ?? null;
|
|
718
|
+
const cited = [...new Set(obs.flatMap((o) => o.sourceEpisodeIds ?? []))];
|
|
719
|
+
const tombstoned = /* @__PURE__ */ new Set();
|
|
720
|
+
if (cited.length > 0) {
|
|
721
|
+
const { rows } = await client.query(
|
|
722
|
+
`select id from ${EPISODES_TABLE}
|
|
723
|
+
where org = $1 and uid = $2 and id = any($3::text[]) and state = 'tombstoned'`,
|
|
724
|
+
[scope.org, scope.uid, cited]
|
|
725
|
+
);
|
|
726
|
+
for (const row of rows) tombstoned.add(row.id);
|
|
727
|
+
}
|
|
728
|
+
const currentByKey = /* @__PURE__ */ new Map();
|
|
729
|
+
if (keys.length > 0) {
|
|
730
|
+
const { rows } = await client.query(
|
|
731
|
+
`select ${FACT_COLUMNS} from ${FACTS_TABLE}
|
|
732
|
+
where org = $1 and uid = $2 and key = any($3::text[])
|
|
733
|
+
and superseded_at is null and invalidated_at is null`,
|
|
734
|
+
[scope.org, scope.uid, [...keys]]
|
|
735
|
+
);
|
|
736
|
+
for (const row of rows) {
|
|
737
|
+
const fact = toFact(row);
|
|
738
|
+
currentByKey.set(fact.key, fact);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
const existingIds = /* @__PURE__ */ new Set();
|
|
742
|
+
const { rows: replays } = await client.query(
|
|
743
|
+
`select id from ${FACTS_TABLE} where org = $1 and uid = $2 and id = any($3::text[])`,
|
|
744
|
+
[scope.org, scope.uid, [...new Set(candidateIds)]]
|
|
745
|
+
);
|
|
746
|
+
for (const row of replays) existingIds.add(row.id);
|
|
747
|
+
return { watermark, tombstoned, currentByKey, existingIds };
|
|
748
|
+
}
|
|
749
|
+
async #inScope(scope, fn) {
|
|
750
|
+
return inScope(this.#pool, this.#role, scope, fn, this.#timeoutMs);
|
|
751
|
+
}
|
|
752
|
+
};
|
|
753
|
+
function planObservations(obs, trusted, stamps, candidateIds, state) {
|
|
754
|
+
const { currentByKey, existingIds } = state;
|
|
755
|
+
const results = [];
|
|
756
|
+
const writes = [];
|
|
757
|
+
const rounds = /* @__PURE__ */ new Map();
|
|
758
|
+
for (let index = 0; index < obs.length; index++) {
|
|
759
|
+
const o = obs[index];
|
|
760
|
+
const at = stamps[index];
|
|
761
|
+
if (!trusted && isProtectedProfileKey(o.key)) {
|
|
762
|
+
results.push({
|
|
763
|
+
key: o.key,
|
|
764
|
+
outcome: "refused",
|
|
765
|
+
detail: `${JSON.stringify(o.key)} is in the protected profile namespace; only the product may write it`
|
|
766
|
+
});
|
|
767
|
+
continue;
|
|
768
|
+
}
|
|
769
|
+
const dead = (o.sourceEpisodeIds ?? []).filter((id2) => state.tombstoned.has(id2));
|
|
770
|
+
if (dead.length > 0) {
|
|
771
|
+
results.push({
|
|
772
|
+
key: o.key,
|
|
773
|
+
outcome: "stale",
|
|
774
|
+
detail: `cites erased episode(s): ${dead.join(", ")}`
|
|
775
|
+
});
|
|
776
|
+
continue;
|
|
777
|
+
}
|
|
778
|
+
if (state.watermark !== null && at < state.watermark) {
|
|
779
|
+
results.push({
|
|
780
|
+
key: o.key,
|
|
781
|
+
outcome: "stale",
|
|
782
|
+
detail: `observation stamped ${at} precedes this scope's erasure at ${state.watermark}`
|
|
783
|
+
});
|
|
784
|
+
continue;
|
|
785
|
+
}
|
|
786
|
+
const confidence = o.confidence ?? DEFAULT_CONFIDENCE;
|
|
787
|
+
const current = currentByKey.get(o.key);
|
|
788
|
+
const decision = decideObservation(current, { value: o.value, confidence }, { trusted });
|
|
789
|
+
const round = rounds.get(o.key) ?? 0;
|
|
790
|
+
if (decision.outcome === "refreshed" && current !== void 0) {
|
|
791
|
+
const refresh = { confidence, at };
|
|
792
|
+
if (o.sourceEpisodeIds !== void 0) refresh.sourceEpisodeIds = o.sourceEpisodeIds;
|
|
793
|
+
if (o.ttlDays !== void 0) refresh.ttlDays = o.ttlDays;
|
|
794
|
+
const merged = mergeRefresh(current, refresh);
|
|
795
|
+
currentByKey.set(o.key, { ...current, ...merged });
|
|
796
|
+
rounds.set(o.key, round + 1);
|
|
797
|
+
writes.push({ kind: "refresh", round, index, id: current.id, merged });
|
|
798
|
+
results.push({ key: o.key, outcome: "refreshed", factId: current.id });
|
|
799
|
+
continue;
|
|
800
|
+
}
|
|
801
|
+
const id = candidateIds[index];
|
|
802
|
+
if (existingIds.has(id)) {
|
|
803
|
+
results.push({
|
|
804
|
+
key: o.key,
|
|
805
|
+
outcome: "replayed",
|
|
806
|
+
factId: id,
|
|
807
|
+
detail: "this exact observation is already a stored version; nothing was written"
|
|
808
|
+
});
|
|
809
|
+
continue;
|
|
810
|
+
}
|
|
811
|
+
const version = {
|
|
812
|
+
id,
|
|
813
|
+
key: o.key,
|
|
814
|
+
value: o.value,
|
|
815
|
+
confidence,
|
|
816
|
+
sourceEpisodeIds: [...o.sourceEpisodeIds ?? []],
|
|
817
|
+
observedAt: at,
|
|
818
|
+
lastSeenAt: at
|
|
819
|
+
};
|
|
820
|
+
if (o.ttlDays !== void 0) version.ttlDays = o.ttlDays;
|
|
821
|
+
existingIds.add(id);
|
|
822
|
+
rounds.set(o.key, round + 1);
|
|
823
|
+
if (decision.outcome === "conflict" && current !== void 0) {
|
|
824
|
+
version.supersededAt = at;
|
|
825
|
+
version.supersededBy = current.id;
|
|
826
|
+
writes.push({ kind: "insert", round, index, version });
|
|
827
|
+
const conflict = { key: o.key, outcome: "conflict", factId: id };
|
|
828
|
+
if (decision.detail !== void 0) conflict.detail = decision.detail;
|
|
829
|
+
results.push(conflict);
|
|
830
|
+
continue;
|
|
831
|
+
}
|
|
832
|
+
currentByKey.set(o.key, version);
|
|
833
|
+
writes.push(
|
|
834
|
+
decision.outcome === "superseded" && current !== void 0 ? { kind: "insert", round, index, version, closes: current.id } : { kind: "insert", round, index, version }
|
|
835
|
+
);
|
|
836
|
+
const result = { key: o.key, outcome: decision.outcome, factId: id };
|
|
837
|
+
if (decision.detail !== void 0) result.detail = decision.detail;
|
|
838
|
+
results.push(result);
|
|
839
|
+
}
|
|
840
|
+
return { results, writes };
|
|
841
|
+
}
|
|
842
|
+
async function applyWrites(client, scope, writes) {
|
|
843
|
+
const lost = /* @__PURE__ */ new Set();
|
|
844
|
+
const lastRound = writes.reduce((max, w) => Math.max(max, w.round), -1);
|
|
845
|
+
for (let round = 0; round <= lastRound; round++) {
|
|
846
|
+
const refreshes = writes.filter(
|
|
847
|
+
(w) => w.round === round && w.kind === "refresh"
|
|
848
|
+
);
|
|
849
|
+
const inserts = writes.filter(
|
|
850
|
+
(w) => w.round === round && w.kind === "insert"
|
|
851
|
+
);
|
|
852
|
+
if (refreshes.length > 0) {
|
|
853
|
+
const updated = await refreshVersions(client, scope, refreshes);
|
|
854
|
+
for (const w of refreshes) if (!updated.has(w.id)) lost.add(w.index);
|
|
855
|
+
}
|
|
856
|
+
const closes = inserts.flatMap(
|
|
857
|
+
(w) => w.closes === void 0 ? [] : [{ id: w.closes, at: w.version.observedAt, by: w.version.id }]
|
|
858
|
+
);
|
|
859
|
+
if (closes.length > 0) await closeVersions(client, scope, closes);
|
|
860
|
+
if (inserts.length > 0) await insertVersions(client, scope, inserts.map((w) => w.version));
|
|
861
|
+
}
|
|
862
|
+
return lost;
|
|
863
|
+
}
|
|
864
|
+
function placeholderList(params, cells) {
|
|
865
|
+
return cells.map(([value, type]) => {
|
|
866
|
+
params.push(value);
|
|
867
|
+
return `$${params.length}::${type}`;
|
|
868
|
+
}).join(", ");
|
|
869
|
+
}
|
|
870
|
+
async function refreshVersions(client, scope, writes) {
|
|
871
|
+
const params = [scope.org, scope.uid];
|
|
872
|
+
const rows = writes.map(
|
|
873
|
+
(w) => `(${placeholderList(params, [
|
|
874
|
+
[w.id, "text"],
|
|
875
|
+
[w.merged.confidence, "double precision"],
|
|
876
|
+
[w.merged.lastSeenAt, "timestamptz"],
|
|
877
|
+
[[...w.merged.sourceEpisodeIds], "text[]"],
|
|
878
|
+
[w.merged.ttlDays ?? null, "integer"]
|
|
879
|
+
])})`
|
|
880
|
+
);
|
|
881
|
+
const { rows: updated } = await client.query(
|
|
882
|
+
`update ${FACTS_TABLE}
|
|
883
|
+
set confidence = v.confidence, last_seen_at = v.last_seen_at,
|
|
884
|
+
source_episode_ids = v.source_episode_ids, ttl_days = v.ttl_days
|
|
885
|
+
from (values ${rows.join(", ")}) as v(id, confidence, last_seen_at, source_episode_ids, ttl_days)
|
|
886
|
+
where ${FACTS_TABLE}.org = $1 and ${FACTS_TABLE}.uid = $2
|
|
887
|
+
and ${FACTS_TABLE}.id = v.id
|
|
888
|
+
and ${FACTS_TABLE}.superseded_at is null and ${FACTS_TABLE}.invalidated_at is null
|
|
889
|
+
returning ${FACTS_TABLE}.id`,
|
|
890
|
+
params
|
|
891
|
+
);
|
|
892
|
+
return new Set(updated.map((row) => row.id));
|
|
893
|
+
}
|
|
894
|
+
async function closeVersions(client, scope, closes) {
|
|
895
|
+
const params = [scope.org, scope.uid];
|
|
896
|
+
const rows = closes.map(
|
|
897
|
+
(c) => `(${placeholderList(params, [
|
|
898
|
+
[c.id, "text"],
|
|
899
|
+
[c.at, "timestamptz"],
|
|
900
|
+
[c.by, "text"]
|
|
901
|
+
])})`
|
|
902
|
+
);
|
|
903
|
+
await client.query(
|
|
904
|
+
`update ${FACTS_TABLE} set superseded_at = v.at, superseded_by = v.by
|
|
905
|
+
from (values ${rows.join(", ")}) as v(id, at, by)
|
|
906
|
+
where ${FACTS_TABLE}.org = $1 and ${FACTS_TABLE}.uid = $2 and ${FACTS_TABLE}.id = v.id`,
|
|
907
|
+
params
|
|
908
|
+
);
|
|
909
|
+
}
|
|
910
|
+
async function insertVersions(client, scope, versions) {
|
|
911
|
+
const params = [scope.org, scope.uid];
|
|
912
|
+
const rows = versions.map(
|
|
913
|
+
(f) => `($1, $2, ${placeholderList(params, [
|
|
914
|
+
[f.id, "text"],
|
|
915
|
+
[f.key, "text"],
|
|
916
|
+
[f.value, "text"],
|
|
917
|
+
[f.confidence, "double precision"],
|
|
918
|
+
[[...f.sourceEpisodeIds], "text[]"],
|
|
919
|
+
[f.observedAt, "timestamptz"],
|
|
920
|
+
[f.lastSeenAt, "timestamptz"],
|
|
921
|
+
[f.supersededAt ?? null, "timestamptz"],
|
|
922
|
+
[f.supersededBy ?? null, "text"],
|
|
923
|
+
[f.ttlDays ?? null, "integer"]
|
|
924
|
+
])})`
|
|
925
|
+
);
|
|
926
|
+
await client.query(
|
|
927
|
+
`insert into ${FACTS_TABLE}
|
|
928
|
+
(org, uid, id, key, value, confidence, source_episode_ids,
|
|
929
|
+
observed_at, last_seen_at, superseded_at, superseded_by, ttl_days)
|
|
930
|
+
values ${rows.join(", ")}`,
|
|
931
|
+
params
|
|
932
|
+
);
|
|
933
|
+
}
|
|
934
|
+
var PostgresErasureWatermarks = class {
|
|
935
|
+
#pool;
|
|
936
|
+
#role;
|
|
937
|
+
#timeoutMs;
|
|
938
|
+
constructor(pool, opts = {}) {
|
|
939
|
+
this.#pool = pool;
|
|
940
|
+
this.#role = resolveRlsRole(opts);
|
|
941
|
+
this.#timeoutMs = resolveStatementTimeout(opts);
|
|
942
|
+
}
|
|
943
|
+
async get(scope) {
|
|
944
|
+
return inScope(
|
|
945
|
+
this.#pool,
|
|
946
|
+
this.#role,
|
|
947
|
+
scope,
|
|
948
|
+
async (client) => {
|
|
949
|
+
const { rows } = await client.query(
|
|
950
|
+
`select erasure_watermark from ${SCOPE_STATE_TABLE} where org = $1 and uid = $2`,
|
|
951
|
+
[scope.org, scope.uid]
|
|
952
|
+
);
|
|
953
|
+
return rows[0]?.erasure_watermark?.toISOString() ?? null;
|
|
954
|
+
},
|
|
955
|
+
this.#timeoutMs
|
|
956
|
+
);
|
|
957
|
+
}
|
|
958
|
+
async set(scope, rawAt) {
|
|
959
|
+
const at = toIsoInstant(rawAt);
|
|
960
|
+
await inScope(
|
|
961
|
+
this.#pool,
|
|
962
|
+
this.#role,
|
|
963
|
+
scope,
|
|
964
|
+
async (client) => {
|
|
965
|
+
await client.query(
|
|
966
|
+
`insert into ${SCOPE_STATE_TABLE} (org, uid, erasure_watermark)
|
|
967
|
+
values ($1, $2, $3::timestamptz)
|
|
968
|
+
on conflict (org, uid) do update
|
|
969
|
+
set erasure_watermark =
|
|
970
|
+
greatest(${SCOPE_STATE_TABLE}.erasure_watermark, excluded.erasure_watermark)`,
|
|
971
|
+
[scope.org, scope.uid, at]
|
|
972
|
+
);
|
|
973
|
+
},
|
|
974
|
+
this.#timeoutMs
|
|
975
|
+
);
|
|
976
|
+
}
|
|
977
|
+
};
|
|
978
|
+
|
|
979
|
+
// src/spend-schema.ts
|
|
980
|
+
var SPEND_SESSIONS_TABLE = "alma_spend_sessions";
|
|
981
|
+
var SPEND_TENANT_DAYS_TABLE = "alma_spend_tenant_days";
|
|
982
|
+
function spendStoreMigrationSql(role = DEFAULT_RLS_ROLE) {
|
|
983
|
+
assertRoleIdentifier(role);
|
|
984
|
+
return `
|
|
985
|
+
create table if not exists ${SPEND_SESSIONS_TABLE} (
|
|
986
|
+
org text not null,
|
|
987
|
+
uid text not null,
|
|
988
|
+
session_id text not null,
|
|
989
|
+
usd double precision not null default 0,
|
|
990
|
+
updated_at timestamptz not null default now(),
|
|
991
|
+
primary key (org, uid, session_id)
|
|
992
|
+
);
|
|
993
|
+
|
|
994
|
+
create table if not exists ${SPEND_TENANT_DAYS_TABLE} (
|
|
995
|
+
org text not null,
|
|
996
|
+
day date not null,
|
|
997
|
+
usd double precision not null default 0,
|
|
998
|
+
updated_at timestamptz not null default now(),
|
|
999
|
+
primary key (org, day)
|
|
1000
|
+
);
|
|
1001
|
+
${rlsPolicySql(SPEND_SESSIONS_TABLE)}${rlsPolicySql(SPEND_TENANT_DAYS_TABLE, ["org"])}
|
|
1002
|
+
${roleBootstrapSql(role)}
|
|
1003
|
+
grant select, insert, update
|
|
1004
|
+
on ${SPEND_SESSIONS_TABLE}, ${SPEND_TENANT_DAYS_TABLE}
|
|
1005
|
+
to ${role};
|
|
1006
|
+
`;
|
|
1007
|
+
}
|
|
1008
|
+
async function migrateSpendStore(pool, opts = {}) {
|
|
1009
|
+
await pool.query(spendStoreMigrationSql(opts.role));
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
// src/spend-store.ts
|
|
1013
|
+
var PostgresSpendStore = class {
|
|
1014
|
+
#pool;
|
|
1015
|
+
#role;
|
|
1016
|
+
#timeoutMs;
|
|
1017
|
+
constructor(pool, opts = {}) {
|
|
1018
|
+
this.#pool = pool;
|
|
1019
|
+
this.#role = resolveRlsRole(opts);
|
|
1020
|
+
this.#timeoutMs = resolveStatementTimeout(opts);
|
|
1021
|
+
}
|
|
1022
|
+
async add(entry) {
|
|
1023
|
+
if (!Number.isFinite(entry.usd) || entry.usd < 0) {
|
|
1024
|
+
throw new Error(`spend must be a non-negative finite number, got ${entry.usd}`);
|
|
1025
|
+
}
|
|
1026
|
+
const day = utcDayBucket(entry.at);
|
|
1027
|
+
return this.#inScope(entry.scope, async (client) => {
|
|
1028
|
+
const { rows: session } = await client.query(
|
|
1029
|
+
`insert into ${SPEND_SESSIONS_TABLE} (org, uid, session_id, usd)
|
|
1030
|
+
values ($1, $2, $3, $4)
|
|
1031
|
+
on conflict (org, uid, session_id)
|
|
1032
|
+
do update set usd = ${SPEND_SESSIONS_TABLE}.usd + excluded.usd, updated_at = now()
|
|
1033
|
+
returning usd`,
|
|
1034
|
+
[entry.scope.org, entry.scope.uid, entry.sessionId, entry.usd]
|
|
1035
|
+
);
|
|
1036
|
+
const { rows: dayRows } = await client.query(
|
|
1037
|
+
`insert into ${SPEND_TENANT_DAYS_TABLE} (org, day, usd)
|
|
1038
|
+
values ($1, $2::date, $3)
|
|
1039
|
+
on conflict (org, day)
|
|
1040
|
+
do update set usd = ${SPEND_TENANT_DAYS_TABLE}.usd + excluded.usd, updated_at = now()
|
|
1041
|
+
returning usd`,
|
|
1042
|
+
[entry.scope.org, day, entry.usd]
|
|
1043
|
+
);
|
|
1044
|
+
return { sessionUsd: session[0].usd, tenantDayUsd: dayRows[0].usd };
|
|
1045
|
+
});
|
|
1046
|
+
}
|
|
1047
|
+
async peek(key) {
|
|
1048
|
+
const day = utcDayBucket(key.at);
|
|
1049
|
+
return this.#inScope(key.scope, async (client) => {
|
|
1050
|
+
const { rows } = await client.query(
|
|
1051
|
+
`select
|
|
1052
|
+
coalesce((select usd from ${SPEND_SESSIONS_TABLE}
|
|
1053
|
+
where org = $1 and uid = $2 and session_id = $3), 0) as session_usd,
|
|
1054
|
+
coalesce((select usd from ${SPEND_TENANT_DAYS_TABLE}
|
|
1055
|
+
where org = $1 and day = $4::date), 0) as tenant_day_usd`,
|
|
1056
|
+
[key.scope.org, key.scope.uid, key.sessionId, day]
|
|
1057
|
+
);
|
|
1058
|
+
return { sessionUsd: rows[0].session_usd, tenantDayUsd: rows[0].tenant_day_usd };
|
|
1059
|
+
});
|
|
1060
|
+
}
|
|
1061
|
+
/** Shared RLS binding — see `scoped.ts`. */
|
|
1062
|
+
async #inScope(scope, fn) {
|
|
1063
|
+
return inScope(this.#pool, this.#role, scope, fn, this.#timeoutMs);
|
|
1064
|
+
}
|
|
1065
|
+
};
|
|
1066
|
+
function utcDayBucket(at) {
|
|
1067
|
+
const ms = Date.parse(at);
|
|
1068
|
+
if (Number.isNaN(ms)) throw new Error(`invalid ISO 8601 timestamp: ${JSON.stringify(at)}`);
|
|
1069
|
+
return new Date(ms).toISOString().slice(0, 10);
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
// src/audit-schema.ts
|
|
1073
|
+
var AUDIT_ACCESS_TABLE = "alma_audit_access";
|
|
1074
|
+
var AUDIT_ROUTING_TABLE = "alma_audit_routing";
|
|
1075
|
+
var AUDIT_COST_TABLE = "alma_audit_cost";
|
|
1076
|
+
var AUDIT_RECALL_TABLE = "alma_audit_recall";
|
|
1077
|
+
var AUDIT_CONTEXT_TABLE = "alma_audit_context";
|
|
1078
|
+
var DEFAULT_RETENTION_ROLE = "alma_retention";
|
|
1079
|
+
var AUDIT_TABLES = [
|
|
1080
|
+
AUDIT_ACCESS_TABLE,
|
|
1081
|
+
AUDIT_ROUTING_TABLE,
|
|
1082
|
+
AUDIT_COST_TABLE,
|
|
1083
|
+
AUDIT_RECALL_TABLE,
|
|
1084
|
+
AUDIT_CONTEXT_TABLE
|
|
1085
|
+
];
|
|
1086
|
+
function auditLogMigrationSql(role = DEFAULT_RLS_ROLE, retentionRole = DEFAULT_RETENTION_ROLE) {
|
|
1087
|
+
assertRoleIdentifier(role);
|
|
1088
|
+
assertRoleIdentifier(retentionRole);
|
|
1089
|
+
return `
|
|
1090
|
+
create table if not exists ${AUDIT_ACCESS_TABLE} (
|
|
1091
|
+
id uuid not null default gen_random_uuid(),
|
|
1092
|
+
org text not null,
|
|
1093
|
+
uid text not null,
|
|
1094
|
+
at timestamptz not null,
|
|
1095
|
+
tool text not null,
|
|
1096
|
+
action text not null,
|
|
1097
|
+
-- Identifier of the touched resource (id/path) \u2014 NEVER its content.
|
|
1098
|
+
resource text,
|
|
1099
|
+
session_id text,
|
|
1100
|
+
turn_id text,
|
|
1101
|
+
primary key (id),
|
|
1102
|
+
constraint alma_audit_access_action_check
|
|
1103
|
+
check (action in ('read', 'write', 'delete', 'export'))
|
|
1104
|
+
);
|
|
1105
|
+
|
|
1106
|
+
create index if not exists alma_audit_access_turn
|
|
1107
|
+
on ${AUDIT_ACCESS_TABLE} (org, uid, turn_id);
|
|
1108
|
+
|
|
1109
|
+
create index if not exists alma_audit_access_recent
|
|
1110
|
+
on ${AUDIT_ACCESS_TABLE} (org, uid, at desc);
|
|
1111
|
+
|
|
1112
|
+
create table if not exists ${AUDIT_ROUTING_TABLE} (
|
|
1113
|
+
id uuid not null default gen_random_uuid(),
|
|
1114
|
+
org text not null,
|
|
1115
|
+
uid text not null,
|
|
1116
|
+
at timestamptz not null,
|
|
1117
|
+
tier text not null,
|
|
1118
|
+
sensitivity text not null,
|
|
1119
|
+
model_provider text not null,
|
|
1120
|
+
model_id text not null,
|
|
1121
|
+
-- Carried verbatim from ModelChoice.rationale: a policy must explain itself.
|
|
1122
|
+
rationale text not null,
|
|
1123
|
+
session_id text,
|
|
1124
|
+
turn_id text,
|
|
1125
|
+
primary key (id)
|
|
1126
|
+
);
|
|
1127
|
+
|
|
1128
|
+
create index if not exists alma_audit_routing_turn
|
|
1129
|
+
on ${AUDIT_ROUTING_TABLE} (org, uid, turn_id);
|
|
1130
|
+
|
|
1131
|
+
create table if not exists ${AUDIT_COST_TABLE} (
|
|
1132
|
+
id uuid not null default gen_random_uuid(),
|
|
1133
|
+
org text not null,
|
|
1134
|
+
uid text not null,
|
|
1135
|
+
at timestamptz not null,
|
|
1136
|
+
model_provider text not null,
|
|
1137
|
+
model_id text not null,
|
|
1138
|
+
input_tokens bigint not null,
|
|
1139
|
+
output_tokens bigint not null,
|
|
1140
|
+
cache_read_input_tokens bigint,
|
|
1141
|
+
cache_write_input_tokens bigint,
|
|
1142
|
+
-- double precision, like every fractional number in this package: the whole
|
|
1143
|
+
-- pricing pipeline computes in JS floats, and NUMERIC round-trips as a
|
|
1144
|
+
-- string the driver does not sum (spec: spend-store).
|
|
1145
|
+
cost_usd double precision not null,
|
|
1146
|
+
caps_crossed text[] not null default '{}',
|
|
1147
|
+
session_id text,
|
|
1148
|
+
turn_id text,
|
|
1149
|
+
primary key (id)
|
|
1150
|
+
);
|
|
1151
|
+
|
|
1152
|
+
create index if not exists alma_audit_cost_turn
|
|
1153
|
+
on ${AUDIT_COST_TABLE} (org, uid, turn_id);
|
|
1154
|
+
|
|
1155
|
+
create index if not exists alma_audit_cost_recent
|
|
1156
|
+
on ${AUDIT_COST_TABLE} (org, uid, at desc);
|
|
1157
|
+
|
|
1158
|
+
create table if not exists ${AUDIT_RECALL_TABLE} (
|
|
1159
|
+
id uuid not null default gen_random_uuid(),
|
|
1160
|
+
org text not null,
|
|
1161
|
+
uid text not null,
|
|
1162
|
+
at timestamptz not null,
|
|
1163
|
+
session_id text not null,
|
|
1164
|
+
turn_id text not null,
|
|
1165
|
+
-- Provenance, never the recalled text: a verbatim copy would be a surface
|
|
1166
|
+
-- erasure cannot reach (spec 012).
|
|
1167
|
+
fact_ids text[] not null default '{}',
|
|
1168
|
+
episode_ids text[] not null default '{}',
|
|
1169
|
+
budget_tokens bigint not null,
|
|
1170
|
+
estimated_tokens bigint not null,
|
|
1171
|
+
dropped_tokens bigint,
|
|
1172
|
+
truncated boolean not null,
|
|
1173
|
+
degraded_tiers text[] not null default '{}',
|
|
1174
|
+
primary key (id)
|
|
1175
|
+
);
|
|
1176
|
+
|
|
1177
|
+
create index if not exists alma_audit_recall_turn
|
|
1178
|
+
on ${AUDIT_RECALL_TABLE} (org, uid, turn_id);
|
|
1179
|
+
|
|
1180
|
+
create table if not exists ${AUDIT_CONTEXT_TABLE} (
|
|
1181
|
+
id uuid not null default gen_random_uuid(),
|
|
1182
|
+
org text not null,
|
|
1183
|
+
uid text not null,
|
|
1184
|
+
at timestamptz not null,
|
|
1185
|
+
session_id text not null,
|
|
1186
|
+
turn_id text not null,
|
|
1187
|
+
step integer not null,
|
|
1188
|
+
delegate boolean not null default false,
|
|
1189
|
+
changed text[] not null,
|
|
1190
|
+
refused text[] not null default '{}',
|
|
1191
|
+
-- The two shapes, flattened. Six numbers should be six numbers (spec 038).
|
|
1192
|
+
before_system_blocks bigint not null,
|
|
1193
|
+
before_system_chars bigint not null,
|
|
1194
|
+
before_messages bigint not null,
|
|
1195
|
+
before_message_blocks bigint not null,
|
|
1196
|
+
before_message_chars bigint not null,
|
|
1197
|
+
before_max_tokens bigint not null,
|
|
1198
|
+
after_system_blocks bigint not null,
|
|
1199
|
+
after_system_chars bigint not null,
|
|
1200
|
+
after_messages bigint not null,
|
|
1201
|
+
after_message_blocks bigint not null,
|
|
1202
|
+
after_message_chars bigint not null,
|
|
1203
|
+
after_max_tokens bigint not null,
|
|
1204
|
+
primary key (id)
|
|
1205
|
+
);
|
|
1206
|
+
|
|
1207
|
+
create index if not exists alma_audit_context_turn
|
|
1208
|
+
on ${AUDIT_CONTEXT_TABLE} (org, uid, turn_id);
|
|
1209
|
+
${AUDIT_TABLES.map((t) => rlsPolicySql(t)).join("")}
|
|
1210
|
+
${roleBootstrapSql(role)}
|
|
1211
|
+
${roleBootstrapSql(retentionRole)}
|
|
1212
|
+
grant select, insert
|
|
1213
|
+
on ${AUDIT_TABLES.join(", ")}
|
|
1214
|
+
to ${role};
|
|
1215
|
+
|
|
1216
|
+
-- Retention is a DIFFERENT ACTOR from erasure (spec 039). The app role above
|
|
1217
|
+
-- cannot delete, so a scoped purge can never remove the proof an erasure
|
|
1218
|
+
-- happened; this role can delete any row, and only that \u2014 the grant below
|
|
1219
|
+
-- withholds insert and update from it.
|
|
1220
|
+
--
|
|
1221
|
+
-- These policies are not optional decoration. Every audit table carries FORCE
|
|
1222
|
+
-- ROW LEVEL SECURITY, which subjects even the table OWNER to the predicate, so
|
|
1223
|
+
-- a sweep running as any non-superuser matched the scope-keyed policy, found
|
|
1224
|
+
-- nothing, and deleted zero rows while reporting success. That is what the spec
|
|
1225
|
+
-- 039 review caught: a retention mechanism that silently retains forever, in
|
|
1226
|
+
-- exactly the deployments careful enough not to connect as a superuser.
|
|
1227
|
+
--
|
|
1228
|
+
-- TWO policies, not one. A FOR DELETE policy alone still deleted nothing,
|
|
1229
|
+
-- because a DELETE with a WHERE clause must SCAN the rows to filter them, and
|
|
1230
|
+
-- the scope-keyed policy is FOR ALL -- which governs SELECT too. That second
|
|
1231
|
+
-- step surfaced only by running the statement; it does not follow from reading
|
|
1232
|
+
-- the first fix.
|
|
1233
|
+
${AUDIT_TABLES.map(
|
|
1234
|
+
(t) => `
|
|
1235
|
+
drop policy if exists alma_audit_retention_read on ${t};
|
|
1236
|
+
create policy alma_audit_retention_read on ${t}
|
|
1237
|
+
for select
|
|
1238
|
+
to ${retentionRole}
|
|
1239
|
+
using (true);
|
|
1240
|
+
|
|
1241
|
+
drop policy if exists alma_audit_retention on ${t};
|
|
1242
|
+
create policy alma_audit_retention on ${t}
|
|
1243
|
+
for delete
|
|
1244
|
+
to ${retentionRole}
|
|
1245
|
+
using (true);`
|
|
1246
|
+
).join("")}
|
|
1247
|
+
|
|
1248
|
+
grant select, delete
|
|
1249
|
+
on ${AUDIT_TABLES.join(", ")}
|
|
1250
|
+
to ${retentionRole};
|
|
1251
|
+
`;
|
|
1252
|
+
}
|
|
1253
|
+
async function migrateAuditLog(pool, opts = {}) {
|
|
1254
|
+
await pool.query(auditLogMigrationSql(opts.role, opts.retentionRole));
|
|
1255
|
+
}
|
|
1256
|
+
async function purgeAuditBefore(pool, windows, opts = {}) {
|
|
1257
|
+
const retentionRole = opts.retentionRole ?? DEFAULT_RETENTION_ROLE;
|
|
1258
|
+
assertRoleIdentifier(retentionRole);
|
|
1259
|
+
for (const table of AUDIT_TABLES) {
|
|
1260
|
+
const before = windows[table];
|
|
1261
|
+
if (before !== void 0 && Number.isNaN(Date.parse(before))) {
|
|
1262
|
+
throw new Error(`invalid ISO 8601 timestamp for ${table}: ${JSON.stringify(before)}`);
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
const purged = {};
|
|
1266
|
+
const client = await pool.connect();
|
|
1267
|
+
try {
|
|
1268
|
+
await client.query("begin");
|
|
1269
|
+
await client.query(`set local role ${retentionRole}`);
|
|
1270
|
+
for (const table of AUDIT_TABLES) {
|
|
1271
|
+
const before = windows[table];
|
|
1272
|
+
if (before === void 0) continue;
|
|
1273
|
+
const { rowCount } = await client.query(
|
|
1274
|
+
`delete from ${table} where at < $1::timestamptz`,
|
|
1275
|
+
[before]
|
|
1276
|
+
);
|
|
1277
|
+
purged[table] = rowCount ?? 0;
|
|
1278
|
+
}
|
|
1279
|
+
await client.query("commit");
|
|
1280
|
+
return purged;
|
|
1281
|
+
} catch (err) {
|
|
1282
|
+
await client.query("rollback").catch(() => void 0);
|
|
1283
|
+
throw err;
|
|
1284
|
+
} finally {
|
|
1285
|
+
client.release();
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
// src/audit-store.ts
|
|
1290
|
+
var PostgresAuditLog = class {
|
|
1291
|
+
#pool;
|
|
1292
|
+
#role;
|
|
1293
|
+
#timeoutMs;
|
|
1294
|
+
constructor(pool, opts = {}) {
|
|
1295
|
+
this.#pool = pool;
|
|
1296
|
+
this.#role = resolveRlsRole(opts);
|
|
1297
|
+
this.#timeoutMs = resolveStatementTimeout(opts);
|
|
1298
|
+
}
|
|
1299
|
+
async access(e) {
|
|
1300
|
+
await this.#write(
|
|
1301
|
+
e.scope,
|
|
1302
|
+
(client) => client.query(
|
|
1303
|
+
`insert into ${AUDIT_ACCESS_TABLE}
|
|
1304
|
+
(org, uid, at, tool, action, resource, session_id, turn_id)
|
|
1305
|
+
values ($1, $2, $3::timestamptz, $4, $5, $6, $7, $8)`,
|
|
1306
|
+
[
|
|
1307
|
+
e.scope.org,
|
|
1308
|
+
e.scope.uid,
|
|
1309
|
+
instant2(e.at),
|
|
1310
|
+
e.tool,
|
|
1311
|
+
e.action,
|
|
1312
|
+
e.resource ?? null,
|
|
1313
|
+
e.sessionId ?? null,
|
|
1314
|
+
e.turnId ?? null
|
|
1315
|
+
]
|
|
1316
|
+
)
|
|
1317
|
+
);
|
|
1318
|
+
}
|
|
1319
|
+
async routing(e) {
|
|
1320
|
+
await this.#write(
|
|
1321
|
+
e.scope,
|
|
1322
|
+
(client) => client.query(
|
|
1323
|
+
`insert into ${AUDIT_ROUTING_TABLE}
|
|
1324
|
+
(org, uid, at, tier, sensitivity, model_provider, model_id, rationale,
|
|
1325
|
+
session_id, turn_id)
|
|
1326
|
+
values ($1, $2, $3::timestamptz, $4, $5, $6, $7, $8, $9, $10)`,
|
|
1327
|
+
[
|
|
1328
|
+
e.scope.org,
|
|
1329
|
+
e.scope.uid,
|
|
1330
|
+
instant2(e.at),
|
|
1331
|
+
e.tier,
|
|
1332
|
+
e.sensitivity,
|
|
1333
|
+
e.model.provider,
|
|
1334
|
+
e.model.id,
|
|
1335
|
+
e.rationale,
|
|
1336
|
+
e.sessionId ?? null,
|
|
1337
|
+
e.turnId ?? null
|
|
1338
|
+
]
|
|
1339
|
+
)
|
|
1340
|
+
);
|
|
1341
|
+
}
|
|
1342
|
+
async cost(e) {
|
|
1343
|
+
await this.#write(
|
|
1344
|
+
e.scope,
|
|
1345
|
+
(client) => client.query(
|
|
1346
|
+
`insert into ${AUDIT_COST_TABLE}
|
|
1347
|
+
(org, uid, at, model_provider, model_id, input_tokens, output_tokens,
|
|
1348
|
+
cache_read_input_tokens, cache_write_input_tokens, cost_usd,
|
|
1349
|
+
caps_crossed, session_id, turn_id)
|
|
1350
|
+
values ($1, $2, $3::timestamptz, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`,
|
|
1351
|
+
[
|
|
1352
|
+
e.scope.org,
|
|
1353
|
+
e.scope.uid,
|
|
1354
|
+
instant2(e.at),
|
|
1355
|
+
e.model.provider,
|
|
1356
|
+
e.model.id,
|
|
1357
|
+
e.usage.inputTokens,
|
|
1358
|
+
e.usage.outputTokens,
|
|
1359
|
+
// Absent stays absent rather than becoming a reported zero — the
|
|
1360
|
+
// distinction `addUsage` is careful about, preserved at the boundary.
|
|
1361
|
+
e.usage.cacheReadInputTokens ?? null,
|
|
1362
|
+
e.usage.cacheWriteInputTokens ?? null,
|
|
1363
|
+
e.costUsd,
|
|
1364
|
+
[...e.capsCrossed ?? []],
|
|
1365
|
+
e.sessionId ?? null,
|
|
1366
|
+
e.turnId ?? null
|
|
1367
|
+
]
|
|
1368
|
+
)
|
|
1369
|
+
);
|
|
1370
|
+
}
|
|
1371
|
+
async recall(e) {
|
|
1372
|
+
await this.#write(
|
|
1373
|
+
e.scope,
|
|
1374
|
+
(client) => client.query(
|
|
1375
|
+
`insert into ${AUDIT_RECALL_TABLE}
|
|
1376
|
+
(org, uid, at, session_id, turn_id, fact_ids, episode_ids,
|
|
1377
|
+
budget_tokens, estimated_tokens, dropped_tokens, truncated,
|
|
1378
|
+
degraded_tiers)
|
|
1379
|
+
values ($1, $2, $3::timestamptz, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
|
|
1380
|
+
[
|
|
1381
|
+
e.scope.org,
|
|
1382
|
+
e.scope.uid,
|
|
1383
|
+
instant2(e.at),
|
|
1384
|
+
e.sessionId,
|
|
1385
|
+
e.turnId,
|
|
1386
|
+
[...e.factIds],
|
|
1387
|
+
[...e.episodeIds],
|
|
1388
|
+
e.budgetTokens,
|
|
1389
|
+
e.estimatedTokens,
|
|
1390
|
+
e.droppedTokens ?? null,
|
|
1391
|
+
e.truncated,
|
|
1392
|
+
[...e.degradedTiers ?? []]
|
|
1393
|
+
]
|
|
1394
|
+
)
|
|
1395
|
+
);
|
|
1396
|
+
}
|
|
1397
|
+
async context(e) {
|
|
1398
|
+
await this.#write(
|
|
1399
|
+
e.scope,
|
|
1400
|
+
(client) => client.query(
|
|
1401
|
+
`insert into ${AUDIT_CONTEXT_TABLE}
|
|
1402
|
+
(org, uid, at, session_id, turn_id, step, delegate, changed, refused,
|
|
1403
|
+
before_system_blocks, before_system_chars, before_messages,
|
|
1404
|
+
before_message_blocks, before_message_chars, before_max_tokens,
|
|
1405
|
+
after_system_blocks, after_system_chars, after_messages,
|
|
1406
|
+
after_message_blocks, after_message_chars, after_max_tokens)
|
|
1407
|
+
values ($1, $2, $3::timestamptz, $4, $5, $6, $7, $8, $9,
|
|
1408
|
+
$10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21)`,
|
|
1409
|
+
[
|
|
1410
|
+
e.scope.org,
|
|
1411
|
+
e.scope.uid,
|
|
1412
|
+
instant2(e.at),
|
|
1413
|
+
e.sessionId,
|
|
1414
|
+
e.turnId,
|
|
1415
|
+
e.step,
|
|
1416
|
+
e.delegate ?? false,
|
|
1417
|
+
[...e.changed],
|
|
1418
|
+
[...e.refused ?? []],
|
|
1419
|
+
e.before.systemBlocks,
|
|
1420
|
+
e.before.systemChars,
|
|
1421
|
+
e.before.messages,
|
|
1422
|
+
e.before.messageBlocks,
|
|
1423
|
+
e.before.messageChars,
|
|
1424
|
+
e.before.maxTokens,
|
|
1425
|
+
e.after.systemBlocks,
|
|
1426
|
+
e.after.systemChars,
|
|
1427
|
+
e.after.messages,
|
|
1428
|
+
e.after.messageBlocks,
|
|
1429
|
+
e.after.messageChars,
|
|
1430
|
+
e.after.maxTokens
|
|
1431
|
+
]
|
|
1432
|
+
)
|
|
1433
|
+
);
|
|
1434
|
+
}
|
|
1435
|
+
/** Shared RLS binding — see `scoped.ts`. */
|
|
1436
|
+
async #write(scope, run) {
|
|
1437
|
+
await inScope(this.#pool, this.#role, scope, async (client) => {
|
|
1438
|
+
await run(client);
|
|
1439
|
+
}, this.#timeoutMs);
|
|
1440
|
+
}
|
|
1441
|
+
};
|
|
1442
|
+
function instant2(at) {
|
|
1443
|
+
if (Number.isNaN(Date.parse(at))) {
|
|
1444
|
+
throw new Error(`invalid ISO 8601 timestamp: ${JSON.stringify(at)}`);
|
|
1445
|
+
}
|
|
1446
|
+
return at;
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
// src/turn-store.ts
|
|
1450
|
+
import {
|
|
1451
|
+
assertWellFormed as assertWellFormed2,
|
|
1452
|
+
scopePath as scopePath4
|
|
1453
|
+
} from "@alma-harness/core";
|
|
1454
|
+
|
|
1455
|
+
// src/turn-schema.ts
|
|
1456
|
+
var TURN_LEASES_TABLE = "alma_turn_leases";
|
|
1457
|
+
var TURN_CLAIMS_TABLE = "alma_turn_claims";
|
|
1458
|
+
function turnStoreMigrationSql(role = DEFAULT_RLS_ROLE) {
|
|
1459
|
+
assertRoleIdentifier(role);
|
|
1460
|
+
return `
|
|
1461
|
+
create table if not exists ${TURN_LEASES_TABLE} (
|
|
1462
|
+
org text not null,
|
|
1463
|
+
uid text not null,
|
|
1464
|
+
session_id text not null,
|
|
1465
|
+
token text not null,
|
|
1466
|
+
expires_at timestamptz not null,
|
|
1467
|
+
primary key (org, uid, session_id)
|
|
1468
|
+
);
|
|
1469
|
+
|
|
1470
|
+
create table if not exists ${TURN_CLAIMS_TABLE} (
|
|
1471
|
+
org text not null,
|
|
1472
|
+
uid text not null,
|
|
1473
|
+
session_id text not null,
|
|
1474
|
+
idempotency_key text not null,
|
|
1475
|
+
completed jsonb,
|
|
1476
|
+
created_at timestamptz not null default now(),
|
|
1477
|
+
primary key (org, uid, session_id, idempotency_key)
|
|
1478
|
+
);
|
|
1479
|
+
${rlsPolicySql(TURN_LEASES_TABLE)}${rlsPolicySql(TURN_CLAIMS_TABLE)}
|
|
1480
|
+
${roleBootstrapSql(role)}
|
|
1481
|
+
grant select, insert, update, delete
|
|
1482
|
+
on ${TURN_LEASES_TABLE}, ${TURN_CLAIMS_TABLE}
|
|
1483
|
+
to ${role};
|
|
1484
|
+
`;
|
|
1485
|
+
}
|
|
1486
|
+
async function migrateTurnStore(pool, opts = {}) {
|
|
1487
|
+
await pool.query(turnStoreMigrationSql(opts.role));
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
// src/turn-store.ts
|
|
1491
|
+
var PostgresTurnStore = class {
|
|
1492
|
+
#pool;
|
|
1493
|
+
#role;
|
|
1494
|
+
#timeoutMs;
|
|
1495
|
+
constructor(pool, opts = {}) {
|
|
1496
|
+
this.#pool = pool;
|
|
1497
|
+
this.#role = resolveRlsRole(opts);
|
|
1498
|
+
this.#timeoutMs = resolveStatementTimeout(opts);
|
|
1499
|
+
}
|
|
1500
|
+
async acquire(scope, sessionId, opts) {
|
|
1501
|
+
assertLeaseOpts(opts);
|
|
1502
|
+
scopePath4(scope);
|
|
1503
|
+
const deadline = Date.now() + opts.waitMs;
|
|
1504
|
+
let backoffMs = 25;
|
|
1505
|
+
for (; ; ) {
|
|
1506
|
+
const lease = await this.#tryAcquire(scope, sessionId, opts.ttlMs);
|
|
1507
|
+
if (lease !== null) return lease;
|
|
1508
|
+
const remaining = deadline - Date.now();
|
|
1509
|
+
if (remaining <= 0) return null;
|
|
1510
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(backoffMs, remaining)));
|
|
1511
|
+
backoffMs = Math.min(backoffMs * 2, 250);
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
async #tryAcquire(scope, sessionId, ttlMs) {
|
|
1515
|
+
const token = crypto.randomUUID();
|
|
1516
|
+
return this.#inScope(scope, async (client) => {
|
|
1517
|
+
const { rows } = await client.query(
|
|
1518
|
+
// The `where` on the conflict target is what makes this atomic: a live
|
|
1519
|
+
// lease makes the update match nothing, so no row comes back and the
|
|
1520
|
+
// caller lost. Expiry is compared against the SERVER's clock, so two
|
|
1521
|
+
// app instances with drifting clocks cannot disagree about whether a
|
|
1522
|
+
// lease is live — which is how the same session reaches two holders.
|
|
1523
|
+
`insert into ${TURN_LEASES_TABLE} (org, uid, session_id, token, expires_at)
|
|
1524
|
+
values ($1, $2, $3, $4, now() + make_interval(secs => $5::double precision))
|
|
1525
|
+
on conflict (org, uid, session_id) do update
|
|
1526
|
+
set token = excluded.token, expires_at = excluded.expires_at
|
|
1527
|
+
where ${TURN_LEASES_TABLE}.expires_at <= now()
|
|
1528
|
+
returning token, expires_at`,
|
|
1529
|
+
[scope.org, scope.uid, sessionId, token, ttlMs / 1e3]
|
|
1530
|
+
);
|
|
1531
|
+
const row = rows[0];
|
|
1532
|
+
return row === void 0 ? null : { token: row.token, expiresAt: row.expires_at.toISOString() };
|
|
1533
|
+
});
|
|
1534
|
+
}
|
|
1535
|
+
async release(scope, sessionId, lease) {
|
|
1536
|
+
await this.#inScope(scope, async (client) => {
|
|
1537
|
+
await client.query(
|
|
1538
|
+
`delete from ${TURN_LEASES_TABLE}
|
|
1539
|
+
where org = $1 and uid = $2 and session_id = $3 and token = $4`,
|
|
1540
|
+
[scope.org, scope.uid, sessionId, lease.token]
|
|
1541
|
+
);
|
|
1542
|
+
});
|
|
1543
|
+
}
|
|
1544
|
+
async claim(key) {
|
|
1545
|
+
return this.#inScope(key.scope, async (client) => {
|
|
1546
|
+
await client.query(
|
|
1547
|
+
`insert into ${TURN_CLAIMS_TABLE} (org, uid, session_id, idempotency_key)
|
|
1548
|
+
values ($1, $2, $3, $4)
|
|
1549
|
+
on conflict (org, uid, session_id, idempotency_key) do nothing`,
|
|
1550
|
+
[key.scope.org, key.scope.uid, key.sessionId, key.idempotencyKey]
|
|
1551
|
+
);
|
|
1552
|
+
const { rows } = await client.query(
|
|
1553
|
+
`select completed from ${TURN_CLAIMS_TABLE}
|
|
1554
|
+
where org = $1 and uid = $2 and session_id = $3 and idempotency_key = $4`,
|
|
1555
|
+
[key.scope.org, key.scope.uid, key.sessionId, key.idempotencyKey]
|
|
1556
|
+
);
|
|
1557
|
+
const completed = rows[0]?.completed ?? null;
|
|
1558
|
+
return completed === null ? { status: "fresh" } : { status: "replay", completed };
|
|
1559
|
+
});
|
|
1560
|
+
}
|
|
1561
|
+
/**
|
|
1562
|
+
* PRECONDITION: every string in `completed`, keys included, is well-formed
|
|
1563
|
+
* UTF-16 — `jsonb` refuses a lone surrogate and the write fails (spec:
|
|
1564
|
+
* well-formed-text).
|
|
1565
|
+
*
|
|
1566
|
+
* The loop repairs it on the way in: `reply` is drawn from messages
|
|
1567
|
+
* `record()` passed through `toWellFormedDeep`. But that repair is
|
|
1568
|
+
* BEST-EFFORT by design — its `catch` keeps the unrepaired message, because
|
|
1569
|
+
* failing to repair must never cost more than not having tried — so a
|
|
1570
|
+
* pathologically nested payload can still arrive malformed.
|
|
1571
|
+
*
|
|
1572
|
+
* Now ENFORCED here and in the in-memory reference alike, by the same guard
|
|
1573
|
+
* `SessionStore.append` uses (spec 040). The adapters used to differ — that
|
|
1574
|
+
* store kept the lone surrogate, this one refused the write — which is the
|
|
1575
|
+
* gap spec 025's review recorded and two contracts then documented instead
|
|
1576
|
+
* of closing. The check runs BEFORE the UPDATE and regardless of whether a
|
|
1577
|
+
* row matches, because `$5::jsonb` is parsed either way; the in-memory
|
|
1578
|
+
* reference orders it the same for that reason. The first version of this
|
|
1579
|
+
* comment claimed the loop simply guaranteed it (spec 033).
|
|
1580
|
+
*/
|
|
1581
|
+
async complete(key, completed) {
|
|
1582
|
+
assertWellFormed2(completed, "completed");
|
|
1583
|
+
await this.#inScope(key.scope, async (client) => {
|
|
1584
|
+
await client.query(
|
|
1585
|
+
`update ${TURN_CLAIMS_TABLE} set completed = $5::jsonb
|
|
1586
|
+
where org = $1 and uid = $2 and session_id = $3 and idempotency_key = $4`,
|
|
1587
|
+
[
|
|
1588
|
+
key.scope.org,
|
|
1589
|
+
key.scope.uid,
|
|
1590
|
+
key.sessionId,
|
|
1591
|
+
key.idempotencyKey,
|
|
1592
|
+
JSON.stringify(completed)
|
|
1593
|
+
]
|
|
1594
|
+
);
|
|
1595
|
+
});
|
|
1596
|
+
}
|
|
1597
|
+
async abandon(key) {
|
|
1598
|
+
await this.#inScope(key.scope, async (client) => {
|
|
1599
|
+
await client.query(
|
|
1600
|
+
`delete from ${TURN_CLAIMS_TABLE}
|
|
1601
|
+
where org = $1 and uid = $2 and session_id = $3 and idempotency_key = $4`,
|
|
1602
|
+
[key.scope.org, key.scope.uid, key.sessionId, key.idempotencyKey]
|
|
1603
|
+
);
|
|
1604
|
+
});
|
|
1605
|
+
}
|
|
1606
|
+
async erase(scope, sessionId) {
|
|
1607
|
+
await this.#inScope(scope, async (client) => {
|
|
1608
|
+
const params = sessionId === void 0 ? [scope.org, scope.uid] : [scope.org, scope.uid, sessionId];
|
|
1609
|
+
const bySession = sessionId === void 0 ? "" : " and session_id = $3";
|
|
1610
|
+
for (const table of [TURN_CLAIMS_TABLE, TURN_LEASES_TABLE]) {
|
|
1611
|
+
await client.query(
|
|
1612
|
+
`delete from ${table} where org = $1 and uid = $2${bySession}`,
|
|
1613
|
+
params
|
|
1614
|
+
);
|
|
1615
|
+
}
|
|
1616
|
+
});
|
|
1617
|
+
}
|
|
1618
|
+
/** Shared RLS binding — see `scoped.ts`. */
|
|
1619
|
+
async #inScope(scope, fn) {
|
|
1620
|
+
return inScope(this.#pool, this.#role, scope, fn, this.#timeoutMs);
|
|
1621
|
+
}
|
|
1622
|
+
};
|
|
1623
|
+
function assertLeaseOpts(opts) {
|
|
1624
|
+
for (const [name, value] of [
|
|
1625
|
+
["ttlMs", opts.ttlMs],
|
|
1626
|
+
["waitMs", opts.waitMs]
|
|
1627
|
+
]) {
|
|
1628
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
1629
|
+
throw new Error(`${name} must be a non-negative finite number, got ${value}`);
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
export {
|
|
1634
|
+
AUDIT_ACCESS_TABLE,
|
|
1635
|
+
AUDIT_CONTEXT_TABLE,
|
|
1636
|
+
AUDIT_COST_TABLE,
|
|
1637
|
+
AUDIT_RECALL_TABLE,
|
|
1638
|
+
AUDIT_ROUTING_TABLE,
|
|
1639
|
+
AUDIT_TABLES,
|
|
1640
|
+
DEFAULT_RETENTION_ROLE,
|
|
1641
|
+
DEFAULT_RLS_ROLE,
|
|
1642
|
+
EPISODES_TABLE,
|
|
1643
|
+
FACTS_TABLE,
|
|
1644
|
+
PostgresAuditLog,
|
|
1645
|
+
PostgresEpisodeStore,
|
|
1646
|
+
PostgresErasureWatermarks,
|
|
1647
|
+
PostgresProfileStore,
|
|
1648
|
+
PostgresSessionStore,
|
|
1649
|
+
PostgresSpendStore,
|
|
1650
|
+
PostgresTurnStore,
|
|
1651
|
+
SCOPE_STATE_TABLE,
|
|
1652
|
+
SPEND_SESSIONS_TABLE,
|
|
1653
|
+
SPEND_TENANT_DAYS_TABLE,
|
|
1654
|
+
TURN_CLAIMS_TABLE,
|
|
1655
|
+
TURN_LEASES_TABLE,
|
|
1656
|
+
assertRoleIdentifier,
|
|
1657
|
+
auditLogMigrationSql,
|
|
1658
|
+
memoryStoreMigrationSql,
|
|
1659
|
+
migrateAuditLog,
|
|
1660
|
+
migrateMemoryStores,
|
|
1661
|
+
migrateSessionStore,
|
|
1662
|
+
migrateSpendStore,
|
|
1663
|
+
migrateTurnStore,
|
|
1664
|
+
purgeAuditBefore,
|
|
1665
|
+
rlsPolicySql,
|
|
1666
|
+
roleBootstrapSql,
|
|
1667
|
+
sessionStoreMigrationSql,
|
|
1668
|
+
spendStoreMigrationSql,
|
|
1669
|
+
turnStoreMigrationSql
|
|
1670
|
+
};
|
|
1671
|
+
//# sourceMappingURL=index.js.map
|