@asm-agent/postgres 0.8.2
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/README.md +21 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +59 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +10 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +14 -0
- package/dist/config.js.map +1 -0
- package/dist/database.d.ts +58 -0
- package/dist/database.d.ts.map +1 -0
- package/dist/database.js +373 -0
- package/dist/database.js.map +1 -0
- package/dist/grounded-memory.d.ts +148 -0
- package/dist/grounded-memory.d.ts.map +1 -0
- package/dist/grounded-memory.js +386 -0
- package/dist/grounded-memory.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/repositories.d.ts +53 -0
- package/dist/repositories.d.ts.map +1 -0
- package/dist/repositories.js +2 -0
- package/dist/repositories.js.map +1 -0
- package/dist/types.d.ts +87 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/migrations/0001_foundation.sql +143 -0
- package/migrations/0002_grounded_memory.sql +160 -0
- package/migrations/0003_evidence_cleanup.sql +6 -0
- package/migrations/0004_governance.sql +61 -0
- package/migrations/0005_asm_models.sql +64 -0
- package/migrations/0006_knowledge_workflows.sql +82 -0
- package/migrations/0007_asm_cm_runtime.sql +28 -0
- package/migrations/0008_promotion_security.sql +8 -0
- package/package.json +46 -0
package/README.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# `@asm-agent/postgres`
|
|
2
|
+
|
|
3
|
+
PostgreSQL is ASM Agent's only supported durable application database. Set
|
|
4
|
+
`ASM_AGENT_DATABASE_URL`, run the migration CLI, and use `PostgresStore` from
|
|
5
|
+
the trusted TypeScript runtime. There is intentionally no SQLite or file-store
|
|
6
|
+
fallback in this package.
|
|
7
|
+
|
|
8
|
+
The initial migration creates owner, project, session, message, goal, schedule,
|
|
9
|
+
lease, idempotency, and transactional outbox tables in the `asm_agent` schema.
|
|
10
|
+
|
|
11
|
+
See `docs/postgresql.md` for bootstrap, readiness, backup, restore, and test
|
|
12
|
+
isolation procedures.
|
|
13
|
+
|
|
14
|
+
Grounded-memory inspection and removal are available through the exported
|
|
15
|
+
`GroundedMemoryStore` API and the operator CLI:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
asm-agent-db memory inspect <memory-id> <owner-id> <project-id> <reader-type> <reader-id>
|
|
19
|
+
asm-agent-db memory revoke <memory-id> <owner-id> <project-id>
|
|
20
|
+
asm-agent-db memory erase <memory-id> <owner-id> <project-id>
|
|
21
|
+
```
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"","sourcesContent":["#!/usr/bin/env node\nimport { loadPostgresConfig } from \"./config.js\";\nimport { PostgresStore } from \"./database.js\";\nimport { GroundedMemoryStore, type ReaderType } from \"./grounded-memory.js\";\n\nasync function main(): Promise<void> {\n\tconst command = process.argv[2];\n\tif (command !== \"migrate\" && command !== \"status\" && command !== \"memory\") {\n\t\tthrow new Error(\"Usage: asm-agent-db <migrate|status|memory>\");\n\t}\n\tconst store = new PostgresStore(loadPostgresConfig());\n\ttry {\n\t\tif (command === \"migrate\") await store.migrate();\n\t\tif (command === \"memory\") {\n\t\t\tawait runMemoryCommand(store, process.argv.slice(3));\n\t\t\treturn;\n\t\t}\n\t\tconst report = await store.readiness();\n\t\tconsole.log(JSON.stringify(report, null, 2));\n\t\tif (!report.ready) process.exitCode = 1;\n\t} finally {\n\t\tawait store.close();\n\t}\n}\n\nasync function runMemoryCommand(database: PostgresStore, args: string[]): Promise<void> {\n\tconst [operation, memoryId, ownerId, projectId, readerType, readerId] = args;\n\tif (!operation || !memoryId || !ownerId || !projectId) {\n\t\tthrow new Error(\n\t\t\t\"Usage: asm-agent-db memory <inspect|revoke|erase> <memory-id> <owner-id> <project-id> [reader-type reader-id]\",\n\t\t);\n\t}\n\tconst memory = new GroundedMemoryStore(database);\n\tswitch (operation) {\n\t\tcase \"inspect\": {\n\t\t\tif (!isReaderType(readerType) || !readerId) throw new Error(\"inspect requires reader-type and reader-id\");\n\t\t\tconsole.log(\n\t\t\t\tJSON.stringify(\n\t\t\t\t\tawait memory.inspectMemory(memoryId, { type: readerType, id: readerId }, ownerId, projectId),\n\t\t\t\t\tnull,\n\t\t\t\t\t2,\n\t\t\t\t),\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\t\tcase \"revoke\":\n\t\t\tconsole.log(\n\t\t\t\tJSON.stringify({ revoked: await memory.revokeMemory(memoryId, ownerId, projectId, \"operator_request\") }),\n\t\t\t);\n\t\t\treturn;\n\t\tcase \"erase\": {\n\t\t\tconst jobId = await memory.requestErasure(memoryId, ownerId, projectId);\n\t\t\tconsole.log(JSON.stringify({ jobId, completed: await memory.processErasure(memoryId) }));\n\t\t\treturn;\n\t\t}\n\t\tdefault:\n\t\t\tthrow new Error(`Unknown memory operation: ${operation}`);\n\t}\n}\n\nfunction isReaderType(value: string | undefined): value is ReaderType {\n\treturn value === \"owner\" || value === \"project\" || value === \"session\" || value === \"agent\";\n}\n\nmain().catch((error: unknown) => {\n\tconsole.error(error instanceof Error ? error.message : String(error));\n\tprocess.exitCode = 1;\n});\n"]}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { loadPostgresConfig } from "./config.js";
|
|
3
|
+
import { PostgresStore } from "./database.js";
|
|
4
|
+
import { GroundedMemoryStore } from "./grounded-memory.js";
|
|
5
|
+
async function main() {
|
|
6
|
+
const command = process.argv[2];
|
|
7
|
+
if (command !== "migrate" && command !== "status" && command !== "memory") {
|
|
8
|
+
throw new Error("Usage: asm-agent-db <migrate|status|memory>");
|
|
9
|
+
}
|
|
10
|
+
const store = new PostgresStore(loadPostgresConfig());
|
|
11
|
+
try {
|
|
12
|
+
if (command === "migrate")
|
|
13
|
+
await store.migrate();
|
|
14
|
+
if (command === "memory") {
|
|
15
|
+
await runMemoryCommand(store, process.argv.slice(3));
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const report = await store.readiness();
|
|
19
|
+
console.log(JSON.stringify(report, null, 2));
|
|
20
|
+
if (!report.ready)
|
|
21
|
+
process.exitCode = 1;
|
|
22
|
+
}
|
|
23
|
+
finally {
|
|
24
|
+
await store.close();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
async function runMemoryCommand(database, args) {
|
|
28
|
+
const [operation, memoryId, ownerId, projectId, readerType, readerId] = args;
|
|
29
|
+
if (!operation || !memoryId || !ownerId || !projectId) {
|
|
30
|
+
throw new Error("Usage: asm-agent-db memory <inspect|revoke|erase> <memory-id> <owner-id> <project-id> [reader-type reader-id]");
|
|
31
|
+
}
|
|
32
|
+
const memory = new GroundedMemoryStore(database);
|
|
33
|
+
switch (operation) {
|
|
34
|
+
case "inspect": {
|
|
35
|
+
if (!isReaderType(readerType) || !readerId)
|
|
36
|
+
throw new Error("inspect requires reader-type and reader-id");
|
|
37
|
+
console.log(JSON.stringify(await memory.inspectMemory(memoryId, { type: readerType, id: readerId }, ownerId, projectId), null, 2));
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
case "revoke":
|
|
41
|
+
console.log(JSON.stringify({ revoked: await memory.revokeMemory(memoryId, ownerId, projectId, "operator_request") }));
|
|
42
|
+
return;
|
|
43
|
+
case "erase": {
|
|
44
|
+
const jobId = await memory.requestErasure(memoryId, ownerId, projectId);
|
|
45
|
+
console.log(JSON.stringify({ jobId, completed: await memory.processErasure(memoryId) }));
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
default:
|
|
49
|
+
throw new Error(`Unknown memory operation: ${operation}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function isReaderType(value) {
|
|
53
|
+
return value === "owner" || value === "project" || value === "session" || value === "agent";
|
|
54
|
+
}
|
|
55
|
+
main().catch((error) => {
|
|
56
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
57
|
+
process.exitCode = 1;
|
|
58
|
+
});
|
|
59
|
+
//# sourceMappingURL=cli.js.map
|
package/dist/cli.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"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","sourcesContent":["#!/usr/bin/env node\nimport { loadPostgresConfig } from \"./config.js\";\nimport { PostgresStore } from \"./database.js\";\nimport { GroundedMemoryStore, type ReaderType } from \"./grounded-memory.js\";\n\nasync function main(): Promise<void> {\n\tconst command = process.argv[2];\n\tif (command !== \"migrate\" && command !== \"status\" && command !== \"memory\") {\n\t\tthrow new Error(\"Usage: asm-agent-db <migrate|status|memory>\");\n\t}\n\tconst store = new PostgresStore(loadPostgresConfig());\n\ttry {\n\t\tif (command === \"migrate\") await store.migrate();\n\t\tif (command === \"memory\") {\n\t\t\tawait runMemoryCommand(store, process.argv.slice(3));\n\t\t\treturn;\n\t\t}\n\t\tconst report = await store.readiness();\n\t\tconsole.log(JSON.stringify(report, null, 2));\n\t\tif (!report.ready) process.exitCode = 1;\n\t} finally {\n\t\tawait store.close();\n\t}\n}\n\nasync function runMemoryCommand(database: PostgresStore, args: string[]): Promise<void> {\n\tconst [operation, memoryId, ownerId, projectId, readerType, readerId] = args;\n\tif (!operation || !memoryId || !ownerId || !projectId) {\n\t\tthrow new Error(\n\t\t\t\"Usage: asm-agent-db memory <inspect|revoke|erase> <memory-id> <owner-id> <project-id> [reader-type reader-id]\",\n\t\t);\n\t}\n\tconst memory = new GroundedMemoryStore(database);\n\tswitch (operation) {\n\t\tcase \"inspect\": {\n\t\t\tif (!isReaderType(readerType) || !readerId) throw new Error(\"inspect requires reader-type and reader-id\");\n\t\t\tconsole.log(\n\t\t\t\tJSON.stringify(\n\t\t\t\t\tawait memory.inspectMemory(memoryId, { type: readerType, id: readerId }, ownerId, projectId),\n\t\t\t\t\tnull,\n\t\t\t\t\t2,\n\t\t\t\t),\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\t\tcase \"revoke\":\n\t\t\tconsole.log(\n\t\t\t\tJSON.stringify({ revoked: await memory.revokeMemory(memoryId, ownerId, projectId, \"operator_request\") }),\n\t\t\t);\n\t\t\treturn;\n\t\tcase \"erase\": {\n\t\t\tconst jobId = await memory.requestErasure(memoryId, ownerId, projectId);\n\t\t\tconsole.log(JSON.stringify({ jobId, completed: await memory.processErasure(memoryId) }));\n\t\t\treturn;\n\t\t}\n\t\tdefault:\n\t\t\tthrow new Error(`Unknown memory operation: ${operation}`);\n\t}\n}\n\nfunction isReaderType(value: string | undefined): value is ReaderType {\n\treturn value === \"owner\" || value === \"project\" || value === \"session\" || value === \"agent\";\n}\n\nmain().catch((error: unknown) => {\n\tconsole.error(error instanceof Error ? error.message : String(error));\n\tprocess.exitCode = 1;\n});\n"]}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export declare const ASM_AGENT_DATABASE_URL_ENV = "ASM_AGENT_DATABASE_URL";
|
|
2
|
+
export declare const ASM_AGENT_DATABASE_SCHEMA_ENV = "ASM_AGENT_DATABASE_SCHEMA";
|
|
3
|
+
export interface PostgresConfig {
|
|
4
|
+
connectionString: string;
|
|
5
|
+
schema: "asm_agent";
|
|
6
|
+
maxConnections: number;
|
|
7
|
+
connectionTimeoutMs: number;
|
|
8
|
+
}
|
|
9
|
+
export declare function loadPostgresConfig(environment?: NodeJS.ProcessEnv): PostgresConfig;
|
|
10
|
+
//# sourceMappingURL=config.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,0BAA0B,2BAA2B,CAAC;AACnE,eAAO,MAAM,6BAA6B,8BAA8B,CAAC;AAEzE,MAAM,WAAW,cAAc;IAC9B,gBAAgB,EAAE,MAAM,CAAC;IACzB,MAAM,EAAE,WAAW,CAAC;IACpB,cAAc,EAAE,MAAM,CAAC;IACvB,mBAAmB,EAAE,MAAM,CAAC;CAC5B;AAED,wBAAgB,kBAAkB,CAAC,WAAW,GAAE,MAAM,CAAC,UAAwB,GAAG,cAAc,CAU/F","sourcesContent":["export const ASM_AGENT_DATABASE_URL_ENV = \"ASM_AGENT_DATABASE_URL\";\nexport const ASM_AGENT_DATABASE_SCHEMA_ENV = \"ASM_AGENT_DATABASE_SCHEMA\";\n\nexport interface PostgresConfig {\n\tconnectionString: string;\n\tschema: \"asm_agent\";\n\tmaxConnections: number;\n\tconnectionTimeoutMs: number;\n}\n\nexport function loadPostgresConfig(environment: NodeJS.ProcessEnv = process.env): PostgresConfig {\n\tconst connectionString = environment[ASM_AGENT_DATABASE_URL_ENV]?.trim();\n\tif (!connectionString) {\n\t\tthrow new Error(`${ASM_AGENT_DATABASE_URL_ENV} is required; ASM Agent has no SQLite fallback.`);\n\t}\n\tconst schema = environment[ASM_AGENT_DATABASE_SCHEMA_ENV]?.trim() || \"asm_agent\";\n\tif (schema !== \"asm_agent\") {\n\t\tthrow new Error(`${ASM_AGENT_DATABASE_SCHEMA_ENV} must be asm_agent during the Phase 2 foundation.`);\n\t}\n\treturn { connectionString, schema, maxConnections: 10, connectionTimeoutMs: 5_000 };\n}\n"]}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export const ASM_AGENT_DATABASE_URL_ENV = "ASM_AGENT_DATABASE_URL";
|
|
2
|
+
export const ASM_AGENT_DATABASE_SCHEMA_ENV = "ASM_AGENT_DATABASE_SCHEMA";
|
|
3
|
+
export function loadPostgresConfig(environment = process.env) {
|
|
4
|
+
const connectionString = environment[ASM_AGENT_DATABASE_URL_ENV]?.trim();
|
|
5
|
+
if (!connectionString) {
|
|
6
|
+
throw new Error(`${ASM_AGENT_DATABASE_URL_ENV} is required; ASM Agent has no SQLite fallback.`);
|
|
7
|
+
}
|
|
8
|
+
const schema = environment[ASM_AGENT_DATABASE_SCHEMA_ENV]?.trim() || "asm_agent";
|
|
9
|
+
if (schema !== "asm_agent") {
|
|
10
|
+
throw new Error(`${ASM_AGENT_DATABASE_SCHEMA_ENV} must be asm_agent during the Phase 2 foundation.`);
|
|
11
|
+
}
|
|
12
|
+
return { connectionString, schema, maxConnections: 10, connectionTimeoutMs: 5_000 };
|
|
13
|
+
}
|
|
14
|
+
//# sourceMappingURL=config.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,0BAA0B,GAAG,wBAAwB,CAAC;AACnE,MAAM,CAAC,MAAM,6BAA6B,GAAG,2BAA2B,CAAC;AASzE,MAAM,UAAU,kBAAkB,CAAC,WAAW,GAAsB,OAAO,CAAC,GAAG,EAAkB;IAChG,MAAM,gBAAgB,GAAG,WAAW,CAAC,0BAA0B,CAAC,EAAE,IAAI,EAAE,CAAC;IACzE,IAAI,CAAC,gBAAgB,EAAE,CAAC;QACvB,MAAM,IAAI,KAAK,CAAC,GAAG,0BAA0B,iDAAiD,CAAC,CAAC;IACjG,CAAC;IACD,MAAM,MAAM,GAAG,WAAW,CAAC,6BAA6B,CAAC,EAAE,IAAI,EAAE,IAAI,WAAW,CAAC;IACjF,IAAI,MAAM,KAAK,WAAW,EAAE,CAAC;QAC5B,MAAM,IAAI,KAAK,CAAC,GAAG,6BAA6B,mDAAmD,CAAC,CAAC;IACtG,CAAC;IACD,OAAO,EAAE,gBAAgB,EAAE,MAAM,EAAE,cAAc,EAAE,EAAE,EAAE,mBAAmB,EAAE,KAAK,EAAE,CAAC;AAAA,CACpF","sourcesContent":["export const ASM_AGENT_DATABASE_URL_ENV = \"ASM_AGENT_DATABASE_URL\";\nexport const ASM_AGENT_DATABASE_SCHEMA_ENV = \"ASM_AGENT_DATABASE_SCHEMA\";\n\nexport interface PostgresConfig {\n\tconnectionString: string;\n\tschema: \"asm_agent\";\n\tmaxConnections: number;\n\tconnectionTimeoutMs: number;\n}\n\nexport function loadPostgresConfig(environment: NodeJS.ProcessEnv = process.env): PostgresConfig {\n\tconst connectionString = environment[ASM_AGENT_DATABASE_URL_ENV]?.trim();\n\tif (!connectionString) {\n\t\tthrow new Error(`${ASM_AGENT_DATABASE_URL_ENV} is required; ASM Agent has no SQLite fallback.`);\n\t}\n\tconst schema = environment[ASM_AGENT_DATABASE_SCHEMA_ENV]?.trim() || \"asm_agent\";\n\tif (schema !== \"asm_agent\") {\n\t\tthrow new Error(`${ASM_AGENT_DATABASE_SCHEMA_ENV} must be asm_agent during the Phase 2 foundation.`);\n\t}\n\treturn { connectionString, schema, maxConnections: 10, connectionTimeoutMs: 5_000 };\n}\n"]}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { PoolClient } from "pg";
|
|
2
|
+
import { Pool } from "pg";
|
|
3
|
+
import type { PostgresConfig } from "./config.js";
|
|
4
|
+
import type { DurableRepositories } from "./repositories.js";
|
|
5
|
+
import type { GoalRecord, IdempotencyStartResult, LeaseRecord, OutboxEvent, OwnerRecord, ProjectRecord, ScheduleRecord, SessionMessageInput, SessionMessageRecord, SessionRecord, SessionStatus } from "./types.js";
|
|
6
|
+
export interface ReadinessReport {
|
|
7
|
+
ready: boolean;
|
|
8
|
+
database: "ok" | "unavailable";
|
|
9
|
+
migrations: "current" | "pending" | "unknown";
|
|
10
|
+
pendingMigrations: string[];
|
|
11
|
+
error?: string;
|
|
12
|
+
}
|
|
13
|
+
export declare class PostgresStore implements DurableRepositories {
|
|
14
|
+
readonly pool: Pool;
|
|
15
|
+
constructor(config: PostgresConfig);
|
|
16
|
+
close(): Promise<void>;
|
|
17
|
+
migrate(): Promise<void>;
|
|
18
|
+
readiness(): Promise<ReadinessReport>;
|
|
19
|
+
transaction<T>(operation: (client: PoolClient) => Promise<T>): Promise<T>;
|
|
20
|
+
ensureOwner(record: OwnerRecord): Promise<void>;
|
|
21
|
+
ensureProject(record: ProjectRecord): Promise<void>;
|
|
22
|
+
createSession(input: {
|
|
23
|
+
id: string;
|
|
24
|
+
projectId: string;
|
|
25
|
+
parentSessionId?: string;
|
|
26
|
+
cwd: string;
|
|
27
|
+
name?: string;
|
|
28
|
+
rlmDepth?: number;
|
|
29
|
+
}): Promise<SessionRecord>;
|
|
30
|
+
getSession(id: string): Promise<SessionRecord | undefined>;
|
|
31
|
+
updateSessionStatus(id: string, status: SessionStatus): Promise<boolean>;
|
|
32
|
+
appendMessage(sessionId: string, message: SessionMessageInput): Promise<number>;
|
|
33
|
+
listMessages(sessionId: string, afterSequence?: number): Promise<SessionMessageRecord[]>;
|
|
34
|
+
upsertGoal(goal: GoalRecord): Promise<void>;
|
|
35
|
+
getCurrentGoal(sessionId: string): Promise<GoalRecord | undefined>;
|
|
36
|
+
upsertSchedule(schedule: ScheduleRecord): Promise<void>;
|
|
37
|
+
listDueSchedules(now: Date, limit?: number): Promise<ScheduleRecord[]>;
|
|
38
|
+
acquireLease(input: {
|
|
39
|
+
resourceType: string;
|
|
40
|
+
resourceId: string;
|
|
41
|
+
ownerId: string;
|
|
42
|
+
ttlMs: number;
|
|
43
|
+
}): Promise<LeaseRecord | undefined>;
|
|
44
|
+
renewLease(lease: LeaseRecord, ttlMs: number): Promise<boolean>;
|
|
45
|
+
releaseLease(lease: LeaseRecord): Promise<boolean>;
|
|
46
|
+
startIdempotent(input: {
|
|
47
|
+
scope: string;
|
|
48
|
+
key: string;
|
|
49
|
+
requestHash: string;
|
|
50
|
+
expiresAt?: Date;
|
|
51
|
+
}): Promise<IdempotencyStartResult>;
|
|
52
|
+
completeIdempotent(scope: string, key: string, response: unknown): Promise<void>;
|
|
53
|
+
enqueueOutbox(input: Omit<OutboxEvent, "id" | "attempts">): Promise<string>;
|
|
54
|
+
claimOutbox(workerId: string, limit?: number, lockMs?: number): Promise<OutboxEvent[]>;
|
|
55
|
+
markOutboxPublished(id: string, workerId: string): Promise<boolean>;
|
|
56
|
+
private applyMigration;
|
|
57
|
+
}
|
|
58
|
+
//# sourceMappingURL=database.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"database.d.ts","sourceRoot":"","sources":["../src/database.ts"],"names":[],"mappings":"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","sourcesContent":["import { createHash, randomUUID } from \"node:crypto\";\nimport { readdir, readFile } from \"node:fs/promises\";\nimport type { PoolClient, QueryResult, QueryResultRow } from \"pg\";\nimport { Pool } from \"pg\";\nimport type { PostgresConfig } from \"./config.js\";\nimport type { DurableRepositories } from \"./repositories.js\";\nimport type {\n\tGoalRecord,\n\tIdempotencyStartResult,\n\tLeaseRecord,\n\tOutboxEvent,\n\tOwnerRecord,\n\tProjectRecord,\n\tScheduleRecord,\n\tSessionMessageInput,\n\tSessionMessageRecord,\n\tSessionRecord,\n\tSessionStatus,\n} from \"./types.js\";\n\nconst MIGRATION_LOCK_ID = 4_385_780_217;\n\ninterface MigrationFile {\n\tversion: number;\n\tname: string;\n\tchecksum: string;\n\tsql: string;\n}\n\ninterface MigrationRow extends QueryResultRow {\n\tversion: number;\n\tname: string;\n\tchecksum: string;\n}\n\ninterface SessionRow extends QueryResultRow {\n\tid: string;\n\tproject_id: string;\n\tparent_session_id: string | null;\n\tstatus: SessionStatus;\n\tcwd: string;\n\tname: string | null;\n\trlm_depth: number;\n\ttranscript_revision: string;\n\tcreated_at: Date;\n\tupdated_at: Date;\n}\n\ninterface LeaseRow extends QueryResultRow {\n\tresource_type: string;\n\tresource_id: string;\n\towner_id: string;\n\tfencing_token: string;\n\texpires_at: Date;\n}\n\ninterface OutboxRow extends QueryResultRow {\n\tid: string;\n\ttopic: string;\n\taggregate_type: string;\n\taggregate_id: string;\n\tpayload: unknown;\n\theaders: unknown;\n\tattempts: number;\n}\n\ninterface IdempotencyRow extends QueryResultRow {\n\trequest_hash: string;\n\tstatus: \"started\" | \"completed\" | \"failed\";\n\tresponse: unknown;\n}\n\ninterface MessageRow extends QueryResultRow {\n\tsequence: string;\n\tentry_id: string;\n\tparent_entry_id: string | null;\n\tentry_type: string;\n\tpayload: unknown;\n\tcreated_at: Date;\n}\n\ninterface GoalRow extends QueryResultRow {\n\tid: string;\n\tsession_id: string;\n\tstatus: GoalRecord[\"status\"];\n\tobjective: string;\n\ttoken_budget: string | null;\n\ttokens_used: string;\n\ttime_used_seconds: string;\n\tcontinuations_used: number;\n\tlast_reason: string | null;\n\tlast_error: string | null;\n}\n\ninterface ScheduleRow extends QueryResultRow {\n\tid: string;\n\tsession_id: string;\n\tstatus: ScheduleRecord[\"status\"];\n\tsource: string;\n\tschedule_kind: ScheduleRecord[\"scheduleKind\"];\n\texpression: string;\n\tinterval_ms: string | null;\n\tprompt: string;\n\tmetadata: unknown;\n\tnext_run_at: Date | null;\n\tlast_run_at: Date | null;\n\trun_count: string;\n}\n\nexport interface ReadinessReport {\n\tready: boolean;\n\tdatabase: \"ok\" | \"unavailable\";\n\tmigrations: \"current\" | \"pending\" | \"unknown\";\n\tpendingMigrations: string[];\n\terror?: string;\n}\n\nexport class PostgresStore implements DurableRepositories {\n\treadonly pool: Pool;\n\n\tconstructor(config: PostgresConfig) {\n\t\tthis.pool = new Pool({\n\t\t\tconnectionString: config.connectionString,\n\t\t\tmax: config.maxConnections,\n\t\t\tconnectionTimeoutMillis: config.connectionTimeoutMs,\n\t\t\tapplication_name: \"asm-agent\",\n\t\t});\n\t}\n\n\tasync close(): Promise<void> {\n\t\tawait this.pool.end();\n\t}\n\n\tasync migrate(): Promise<void> {\n\t\tconst migrations = await loadMigrations();\n\t\tconst client = await this.pool.connect();\n\t\ttry {\n\t\t\tawait client.query(\"SELECT pg_advisory_lock($1)\", [MIGRATION_LOCK_ID]);\n\t\t\tfor (const migration of migrations) {\n\t\t\t\tawait this.applyMigration(client, migration);\n\t\t\t}\n\t\t} finally {\n\t\t\tawait client.query(\"SELECT pg_advisory_unlock($1)\", [MIGRATION_LOCK_ID]).catch(() => undefined);\n\t\t\tclient.release();\n\t\t}\n\t}\n\n\tasync readiness(): Promise<ReadinessReport> {\n\t\ttry {\n\t\t\tawait this.pool.query(\"SELECT 1\");\n\t\t\tconst migrations = await loadMigrations();\n\t\t\tconst result = await this.pool.query<MigrationRow>(\n\t\t\t\t\"SELECT version, name, checksum FROM asm_agent.schema_migrations ORDER BY version\",\n\t\t\t);\n\t\t\tconst applied = new Map(result.rows.map((row) => [row.version, row]));\n\t\t\tconst pending = migrations\n\t\t\t\t.filter((migration) => applied.get(migration.version)?.checksum !== migration.checksum)\n\t\t\t\t.map((migration) => migration.name);\n\t\t\treturn {\n\t\t\t\tready: pending.length === 0,\n\t\t\t\tdatabase: \"ok\",\n\t\t\t\tmigrations: pending.length === 0 ? \"current\" : \"pending\",\n\t\t\t\tpendingMigrations: pending,\n\t\t\t};\n\t\t} catch (error) {\n\t\t\treturn {\n\t\t\t\tready: false,\n\t\t\t\tdatabase: \"unavailable\",\n\t\t\t\tmigrations: \"unknown\",\n\t\t\t\tpendingMigrations: [],\n\t\t\t\terror: error instanceof Error ? error.message : String(error),\n\t\t\t};\n\t\t}\n\t}\n\n\tasync transaction<T>(operation: (client: PoolClient) => Promise<T>): Promise<T> {\n\t\tconst client = await this.pool.connect();\n\t\ttry {\n\t\t\tawait client.query(\"BEGIN\");\n\t\t\tconst result = await operation(client);\n\t\t\tawait client.query(\"COMMIT\");\n\t\t\treturn result;\n\t\t} catch (error) {\n\t\t\tawait client.query(\"ROLLBACK\").catch(() => undefined);\n\t\t\tthrow error;\n\t\t} finally {\n\t\t\tclient.release();\n\t\t}\n\t}\n\n\tasync ensureOwner(record: OwnerRecord): Promise<void> {\n\t\tawait this.pool.query(\n\t\t\t`INSERT INTO asm_agent.owners (id, handle) VALUES ($1, $2)\n\t\t\t ON CONFLICT (id) DO UPDATE SET handle = EXCLUDED.handle, updated_at = clock_timestamp()`,\n\t\t\t[record.id, record.handle],\n\t\t);\n\t}\n\n\tasync ensureProject(record: ProjectRecord): Promise<void> {\n\t\tawait this.pool.query(\n\t\t\t`INSERT INTO asm_agent.projects (id, owner_id, key, root_path) VALUES ($1, $2, $3, $4)\n\t\t\t ON CONFLICT (id) DO UPDATE SET key = EXCLUDED.key, root_path = EXCLUDED.root_path,\n\t\t\t updated_at = clock_timestamp()`,\n\t\t\t[record.id, record.ownerId, record.key, record.rootPath],\n\t\t);\n\t}\n\n\tasync createSession(input: {\n\t\tid: string;\n\t\tprojectId: string;\n\t\tparentSessionId?: string;\n\t\tcwd: string;\n\t\tname?: string;\n\t\trlmDepth?: number;\n\t}): Promise<SessionRecord> {\n\t\tconst result = await this.pool.query<SessionRow>(\n\t\t\t`INSERT INTO asm_agent.sessions (id, project_id, parent_session_id, cwd, name, rlm_depth)\n\t\t\t VALUES ($1, $2, $3, $4, $5, $6)\n\t\t\t ON CONFLICT (id) DO UPDATE SET updated_at = clock_timestamp()\n\t\t\t RETURNING *`,\n\t\t\t[input.id, input.projectId, input.parentSessionId ?? null, input.cwd, input.name ?? null, input.rlmDepth ?? 0],\n\t\t);\n\t\treturn sessionFromRow(requiredRow(result, \"create session\"));\n\t}\n\n\tasync getSession(id: string): Promise<SessionRecord | undefined> {\n\t\tconst result = await this.pool.query<SessionRow>(\"SELECT * FROM asm_agent.sessions WHERE id = $1\", [id]);\n\t\treturn result.rows[0] ? sessionFromRow(result.rows[0]) : undefined;\n\t}\n\n\tasync updateSessionStatus(id: string, status: SessionStatus): Promise<boolean> {\n\t\tconst result = await this.pool.query(\n\t\t\t\"UPDATE asm_agent.sessions SET status = $2, updated_at = clock_timestamp() WHERE id = $1\",\n\t\t\t[id, status],\n\t\t);\n\t\treturn (result.rowCount ?? 0) === 1;\n\t}\n\n\tasync appendMessage(sessionId: string, message: SessionMessageInput): Promise<number> {\n\t\treturn this.transaction(async (client) => {\n\t\t\tconst revisionResult = await client.query<{ transcript_revision: string }>(\n\t\t\t\t`UPDATE asm_agent.sessions SET transcript_revision = transcript_revision + 1,\n\t\t\t\t updated_at = clock_timestamp() WHERE id = $1 RETURNING transcript_revision`,\n\t\t\t\t[sessionId],\n\t\t\t);\n\t\t\tconst sequence = Number(requiredRow(revisionResult, \"increment transcript revision\").transcript_revision);\n\t\t\tawait client.query(\n\t\t\t\t`INSERT INTO asm_agent.agent_messages\n\t\t\t\t (session_id, sequence, entry_id, parent_entry_id, entry_type, payload, created_at)\n\t\t\t\t VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7)`,\n\t\t\t\t[\n\t\t\t\t\tsessionId,\n\t\t\t\t\tsequence,\n\t\t\t\t\tmessage.entryId,\n\t\t\t\t\tmessage.parentEntryId ?? null,\n\t\t\t\t\tmessage.entryType,\n\t\t\t\t\tJSON.stringify(message.payload),\n\t\t\t\t\tmessage.createdAt,\n\t\t\t\t],\n\t\t\t);\n\t\t\treturn sequence;\n\t\t});\n\t}\n\n\tasync listMessages(sessionId: string, afterSequence = 0): Promise<SessionMessageRecord[]> {\n\t\tconst result = await this.pool.query<MessageRow>(\n\t\t\t`SELECT sequence, entry_id, parent_entry_id, entry_type, payload, created_at\n\t\t\t FROM asm_agent.agent_messages WHERE session_id = $1 AND sequence > $2 ORDER BY sequence`,\n\t\t\t[sessionId, afterSequence],\n\t\t);\n\t\treturn result.rows.map((row) => ({\n\t\t\tsequence: Number(row.sequence),\n\t\t\tentryId: row.entry_id,\n\t\t\tparentEntryId: row.parent_entry_id ?? undefined,\n\t\t\tentryType: row.entry_type,\n\t\t\tpayload: row.payload,\n\t\t\tcreatedAt: row.created_at,\n\t\t}));\n\t}\n\n\tasync upsertGoal(goal: GoalRecord): Promise<void> {\n\t\tawait this.pool.query(\n\t\t\t`INSERT INTO asm_agent.goals\n\t\t\t (id, session_id, status, objective, token_budget, tokens_used, time_used_seconds,\n\t\t\t continuations_used, last_reason, last_error)\n\t\t\t VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)\n\t\t\t ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, objective = EXCLUDED.objective,\n\t\t\t token_budget = EXCLUDED.token_budget, tokens_used = EXCLUDED.tokens_used,\n\t\t\t time_used_seconds = EXCLUDED.time_used_seconds, continuations_used = EXCLUDED.continuations_used,\n\t\t\t last_reason = EXCLUDED.last_reason, last_error = EXCLUDED.last_error, updated_at = clock_timestamp()`,\n\t\t\t[\n\t\t\t\tgoal.id,\n\t\t\t\tgoal.sessionId,\n\t\t\t\tgoal.status,\n\t\t\t\tgoal.objective,\n\t\t\t\tgoal.tokenBudget ?? null,\n\t\t\t\tgoal.tokensUsed,\n\t\t\t\tgoal.timeUsedSeconds,\n\t\t\t\tgoal.continuationsUsed,\n\t\t\t\tgoal.lastReason ?? null,\n\t\t\t\tgoal.lastError ?? null,\n\t\t\t],\n\t\t);\n\t}\n\n\tasync getCurrentGoal(sessionId: string): Promise<GoalRecord | undefined> {\n\t\tconst result = await this.pool.query<GoalRow>(\n\t\t\t`SELECT * FROM asm_agent.goals WHERE session_id = $1\n\t\t\t ORDER BY updated_at DESC, id DESC LIMIT 1`,\n\t\t\t[sessionId],\n\t\t);\n\t\tconst row = result.rows[0];\n\t\treturn row\n\t\t\t? {\n\t\t\t\t\tid: row.id,\n\t\t\t\t\tsessionId: row.session_id,\n\t\t\t\t\tstatus: row.status,\n\t\t\t\t\tobjective: row.objective,\n\t\t\t\t\ttokenBudget: row.token_budget === null ? undefined : Number(row.token_budget),\n\t\t\t\t\ttokensUsed: Number(row.tokens_used),\n\t\t\t\t\ttimeUsedSeconds: Number(row.time_used_seconds),\n\t\t\t\t\tcontinuationsUsed: row.continuations_used,\n\t\t\t\t\tlastReason: row.last_reason ?? undefined,\n\t\t\t\t\tlastError: row.last_error ?? undefined,\n\t\t\t\t}\n\t\t\t: undefined;\n\t}\n\n\tasync upsertSchedule(schedule: ScheduleRecord): Promise<void> {\n\t\tawait this.pool.query(\n\t\t\t`INSERT INTO asm_agent.schedules\n\t\t\t (id, session_id, status, source, schedule_kind, expression, interval_ms, prompt,\n\t\t\t metadata, next_run_at, last_run_at, run_count)\n\t\t\t VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb,$10,$11,$12)\n\t\t\t ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, source = EXCLUDED.source,\n\t\t\t schedule_kind = EXCLUDED.schedule_kind, expression = EXCLUDED.expression,\n\t\t\t interval_ms = EXCLUDED.interval_ms, prompt = EXCLUDED.prompt, metadata = EXCLUDED.metadata,\n\t\t\t next_run_at = EXCLUDED.next_run_at, last_run_at = EXCLUDED.last_run_at,\n\t\t\t run_count = EXCLUDED.run_count, updated_at = clock_timestamp()`,\n\t\t\t[\n\t\t\t\tschedule.id,\n\t\t\t\tschedule.sessionId,\n\t\t\t\tschedule.status,\n\t\t\t\tschedule.source,\n\t\t\t\tschedule.scheduleKind,\n\t\t\t\tschedule.expression,\n\t\t\t\tschedule.intervalMs ?? null,\n\t\t\t\tschedule.prompt,\n\t\t\t\tJSON.stringify(schedule.metadata),\n\t\t\t\tschedule.nextRunAt ?? null,\n\t\t\t\tschedule.lastRunAt ?? null,\n\t\t\t\tschedule.runCount,\n\t\t\t],\n\t\t);\n\t}\n\n\tasync listDueSchedules(now: Date, limit = 100): Promise<ScheduleRecord[]> {\n\t\tconst result = await this.pool.query<ScheduleRow>(\n\t\t\t`SELECT * FROM asm_agent.schedules WHERE status = 'active' AND next_run_at <= $1\n\t\t\t ORDER BY next_run_at, id LIMIT $2`,\n\t\t\t[now, limit],\n\t\t);\n\t\treturn result.rows.map(scheduleFromRow);\n\t}\n\n\tasync acquireLease(input: {\n\t\tresourceType: string;\n\t\tresourceId: string;\n\t\townerId: string;\n\t\tttlMs: number;\n\t}): Promise<LeaseRecord | undefined> {\n\t\tif (!Number.isInteger(input.ttlMs) || input.ttlMs <= 0) throw new Error(\"Lease ttlMs must be positive\");\n\t\tconst result = await this.pool.query<LeaseRow>(\n\t\t\t`INSERT INTO asm_agent.leases (resource_type, resource_id, owner_id, expires_at)\n\t\t\t VALUES ($1, $2, $3, clock_timestamp() + ($4 * interval '1 millisecond'))\n\t\t\t ON CONFLICT (resource_type, resource_id) DO UPDATE SET\n\t\t\t owner_id = EXCLUDED.owner_id,\n\t\t\t fencing_token = nextval('asm_agent.lease_fencing_token_seq'),\n\t\t\t acquired_at = clock_timestamp(), expires_at = EXCLUDED.expires_at\n\t\t\t WHERE asm_agent.leases.expires_at <= clock_timestamp()\n\t\t\t RETURNING *`,\n\t\t\t[input.resourceType, input.resourceId, input.ownerId, input.ttlMs],\n\t\t);\n\t\treturn result.rows[0] ? leaseFromRow(result.rows[0]) : undefined;\n\t}\n\n\tasync renewLease(lease: LeaseRecord, ttlMs: number): Promise<boolean> {\n\t\tconst result = await this.pool.query(\n\t\t\t`UPDATE asm_agent.leases SET expires_at = clock_timestamp() + ($5 * interval '1 millisecond')\n\t\t\t WHERE resource_type = $1 AND resource_id = $2 AND owner_id = $3 AND fencing_token = $4\n\t\t\t AND expires_at > clock_timestamp()`,\n\t\t\t[lease.resourceType, lease.resourceId, lease.ownerId, lease.fencingToken, ttlMs],\n\t\t);\n\t\treturn (result.rowCount ?? 0) === 1;\n\t}\n\n\tasync releaseLease(lease: LeaseRecord): Promise<boolean> {\n\t\tconst result = await this.pool.query(\n\t\t\t`DELETE FROM asm_agent.leases WHERE resource_type = $1 AND resource_id = $2\n\t\t\t AND owner_id = $3 AND fencing_token = $4`,\n\t\t\t[lease.resourceType, lease.resourceId, lease.ownerId, lease.fencingToken],\n\t\t);\n\t\treturn (result.rowCount ?? 0) === 1;\n\t}\n\n\tasync startIdempotent(input: {\n\t\tscope: string;\n\t\tkey: string;\n\t\trequestHash: string;\n\t\texpiresAt?: Date;\n\t}): Promise<IdempotencyStartResult> {\n\t\treturn this.transaction(async (client) => {\n\t\t\tconst inserted = await client.query(\n\t\t\t\t`INSERT INTO asm_agent.idempotency_records (scope, key, request_hash, status, expires_at)\n\t\t\t\t VALUES ($1, $2, $3, 'started', $4) ON CONFLICT DO NOTHING`,\n\t\t\t\t[input.scope, input.key, input.requestHash, input.expiresAt ?? null],\n\t\t\t);\n\t\t\tif ((inserted.rowCount ?? 0) === 1) return { status: \"started\" };\n\t\t\tconst existing = requiredRow(\n\t\t\t\tawait client.query<IdempotencyRow>(\n\t\t\t\t\t\"SELECT request_hash, status, response FROM asm_agent.idempotency_records WHERE scope = $1 AND key = $2 FOR UPDATE\",\n\t\t\t\t\t[input.scope, input.key],\n\t\t\t\t),\n\t\t\t\t\"load idempotency record\",\n\t\t\t);\n\t\t\tif (existing.request_hash !== input.requestHash)\n\t\t\t\tthrow new Error(\"Idempotency key reused with different request\");\n\t\t\treturn existing.status === \"completed\"\n\t\t\t\t? { status: \"replayed\", response: existing.response }\n\t\t\t\t: { status: \"in_progress\" };\n\t\t});\n\t}\n\n\tasync completeIdempotent(scope: string, key: string, response: unknown): Promise<void> {\n\t\tawait this.pool.query(\n\t\t\t`UPDATE asm_agent.idempotency_records SET status = 'completed', response = $3::jsonb,\n\t\t\t error = NULL, updated_at = clock_timestamp() WHERE scope = $1 AND key = $2`,\n\t\t\t[scope, key, JSON.stringify(response)],\n\t\t);\n\t}\n\n\tasync enqueueOutbox(input: Omit<OutboxEvent, \"id\" | \"attempts\">): Promise<string> {\n\t\tconst id = randomUUID();\n\t\tawait this.pool.query(\n\t\t\t`INSERT INTO asm_agent.outbox_events\n\t\t\t (id, topic, aggregate_type, aggregate_id, payload, headers)\n\t\t\t VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb)`,\n\t\t\t[\n\t\t\t\tid,\n\t\t\t\tinput.topic,\n\t\t\t\tinput.aggregateType,\n\t\t\t\tinput.aggregateId,\n\t\t\t\tJSON.stringify(input.payload),\n\t\t\t\tJSON.stringify(input.headers),\n\t\t\t],\n\t\t);\n\t\treturn id;\n\t}\n\n\tasync claimOutbox(workerId: string, limit = 100, lockMs = 30_000): Promise<OutboxEvent[]> {\n\t\treturn this.transaction(async (client) => {\n\t\t\tconst result = await client.query<OutboxRow>(\n\t\t\t\t`WITH candidates AS (\n\t\t\t\t SELECT id FROM asm_agent.outbox_events\n\t\t\t\t WHERE published_at IS NULL AND available_at <= clock_timestamp()\n\t\t\t\t AND (locked_until IS NULL OR locked_until <= clock_timestamp())\n\t\t\t\t ORDER BY available_at, id FOR UPDATE SKIP LOCKED LIMIT $1\n\t\t\t\t)\n\t\t\t\tUPDATE asm_agent.outbox_events event SET locked_by = $2,\n\t\t\t\t locked_until = clock_timestamp() + ($3 * interval '1 millisecond'), attempts = attempts + 1\n\t\t\t\tFROM candidates WHERE event.id = candidates.id RETURNING event.*`,\n\t\t\t\t[limit, workerId, lockMs],\n\t\t\t);\n\t\t\treturn result.rows.map(outboxFromRow);\n\t\t});\n\t}\n\n\tasync markOutboxPublished(id: string, workerId: string): Promise<boolean> {\n\t\tconst result = await this.pool.query(\n\t\t\t`UPDATE asm_agent.outbox_events SET published_at = clock_timestamp(), locked_by = NULL,\n\t\t\t locked_until = NULL WHERE id = $1 AND locked_by = $2 AND published_at IS NULL`,\n\t\t\t[id, workerId],\n\t\t);\n\t\treturn (result.rowCount ?? 0) === 1;\n\t}\n\n\tprivate async applyMigration(client: PoolClient, migration: MigrationFile): Promise<void> {\n\t\tlet existing: MigrationRow | undefined;\n\t\ttry {\n\t\t\texisting = (\n\t\t\t\tawait client.query<MigrationRow>(\n\t\t\t\t\t\"SELECT version, name, checksum FROM asm_agent.schema_migrations WHERE version = $1\",\n\t\t\t\t\t[migration.version],\n\t\t\t\t)\n\t\t\t).rows[0];\n\t\t} catch (error) {\n\t\t\tif ((error as { code?: string }).code !== \"42P01\") throw error;\n\t\t}\n\t\tif (existing) {\n\t\t\tif (existing.checksum !== migration.checksum) {\n\t\t\t\tthrow new Error(`Migration checksum mismatch: ${migration.name}`);\n\t\t\t}\n\t\t\treturn;\n\t\t}\n\t\tawait client.query(\"BEGIN\");\n\t\ttry {\n\t\t\tawait client.query(migration.sql);\n\t\t\tawait client.query(\"INSERT INTO asm_agent.schema_migrations (version, name, checksum) VALUES ($1, $2, $3)\", [\n\t\t\t\tmigration.version,\n\t\t\t\tmigration.name,\n\t\t\t\tmigration.checksum,\n\t\t\t]);\n\t\t\tawait client.query(\"COMMIT\");\n\t\t} catch (error) {\n\t\t\tawait client.query(\"ROLLBACK\");\n\t\t\tthrow error;\n\t\t}\n\t}\n}\n\nasync function loadMigrations(): Promise<MigrationFile[]> {\n\tconst directory = new URL(\"../migrations/\", import.meta.url);\n\tconst names = (await readdir(directory)).filter((name) => /^\\d+_[a-z0-9_-]+\\.sql$/.test(name)).sort();\n\treturn Promise.all(\n\t\tnames.map(async (name) => {\n\t\t\tconst sql = await readFile(new URL(name, directory), \"utf8\");\n\t\t\treturn {\n\t\t\t\tversion: Number(name.slice(0, name.indexOf(\"_\"))),\n\t\t\t\tname,\n\t\t\t\tchecksum: createHash(\"sha256\").update(sql).digest(\"hex\"),\n\t\t\t\tsql,\n\t\t\t};\n\t\t}),\n\t);\n}\n\nfunction requiredRow<T extends QueryResultRow>(result: QueryResult<T>, operation: string): T {\n\tconst row = result.rows[0];\n\tif (!row) throw new Error(`PostgreSQL did not return a row for ${operation}`);\n\treturn row;\n}\n\nfunction sessionFromRow(row: SessionRow): SessionRecord {\n\treturn {\n\t\tid: row.id,\n\t\tprojectId: row.project_id,\n\t\tparentSessionId: row.parent_session_id ?? undefined,\n\t\tstatus: row.status,\n\t\tcwd: row.cwd,\n\t\tname: row.name ?? undefined,\n\t\trlmDepth: row.rlm_depth,\n\t\ttranscriptRevision: Number(row.transcript_revision),\n\t\tcreatedAt: row.created_at,\n\t\tupdatedAt: row.updated_at,\n\t};\n}\n\nfunction leaseFromRow(row: LeaseRow): LeaseRecord {\n\treturn {\n\t\tresourceType: row.resource_type,\n\t\tresourceId: row.resource_id,\n\t\townerId: row.owner_id,\n\t\tfencingToken: Number(row.fencing_token),\n\t\texpiresAt: row.expires_at,\n\t};\n}\n\nfunction outboxFromRow(row: OutboxRow): OutboxEvent {\n\treturn {\n\t\tid: row.id,\n\t\ttopic: row.topic,\n\t\taggregateType: row.aggregate_type,\n\t\taggregateId: row.aggregate_id,\n\t\tpayload: row.payload,\n\t\theaders: row.headers,\n\t\tattempts: row.attempts,\n\t};\n}\n\nfunction scheduleFromRow(row: ScheduleRow): ScheduleRecord {\n\treturn {\n\t\tid: row.id,\n\t\tsessionId: row.session_id,\n\t\tstatus: row.status,\n\t\tsource: row.source,\n\t\tscheduleKind: row.schedule_kind,\n\t\texpression: row.expression,\n\t\tintervalMs: row.interval_ms === null ? undefined : Number(row.interval_ms),\n\t\tprompt: row.prompt,\n\t\tmetadata: row.metadata,\n\t\tnextRunAt: row.next_run_at ?? undefined,\n\t\tlastRunAt: row.last_run_at ?? undefined,\n\t\trunCount: Number(row.run_count),\n\t};\n}\n"]}
|
package/dist/database.js
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
3
|
+
import { Pool } from "pg";
|
|
4
|
+
const MIGRATION_LOCK_ID = 4_385_780_217;
|
|
5
|
+
export class PostgresStore {
|
|
6
|
+
pool;
|
|
7
|
+
constructor(config) {
|
|
8
|
+
this.pool = new Pool({
|
|
9
|
+
connectionString: config.connectionString,
|
|
10
|
+
max: config.maxConnections,
|
|
11
|
+
connectionTimeoutMillis: config.connectionTimeoutMs,
|
|
12
|
+
application_name: "asm-agent",
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
async close() {
|
|
16
|
+
await this.pool.end();
|
|
17
|
+
}
|
|
18
|
+
async migrate() {
|
|
19
|
+
const migrations = await loadMigrations();
|
|
20
|
+
const client = await this.pool.connect();
|
|
21
|
+
try {
|
|
22
|
+
await client.query("SELECT pg_advisory_lock($1)", [MIGRATION_LOCK_ID]);
|
|
23
|
+
for (const migration of migrations) {
|
|
24
|
+
await this.applyMigration(client, migration);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
await client.query("SELECT pg_advisory_unlock($1)", [MIGRATION_LOCK_ID]).catch(() => undefined);
|
|
29
|
+
client.release();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
async readiness() {
|
|
33
|
+
try {
|
|
34
|
+
await this.pool.query("SELECT 1");
|
|
35
|
+
const migrations = await loadMigrations();
|
|
36
|
+
const result = await this.pool.query("SELECT version, name, checksum FROM asm_agent.schema_migrations ORDER BY version");
|
|
37
|
+
const applied = new Map(result.rows.map((row) => [row.version, row]));
|
|
38
|
+
const pending = migrations
|
|
39
|
+
.filter((migration) => applied.get(migration.version)?.checksum !== migration.checksum)
|
|
40
|
+
.map((migration) => migration.name);
|
|
41
|
+
return {
|
|
42
|
+
ready: pending.length === 0,
|
|
43
|
+
database: "ok",
|
|
44
|
+
migrations: pending.length === 0 ? "current" : "pending",
|
|
45
|
+
pendingMigrations: pending,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
return {
|
|
50
|
+
ready: false,
|
|
51
|
+
database: "unavailable",
|
|
52
|
+
migrations: "unknown",
|
|
53
|
+
pendingMigrations: [],
|
|
54
|
+
error: error instanceof Error ? error.message : String(error),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async transaction(operation) {
|
|
59
|
+
const client = await this.pool.connect();
|
|
60
|
+
try {
|
|
61
|
+
await client.query("BEGIN");
|
|
62
|
+
const result = await operation(client);
|
|
63
|
+
await client.query("COMMIT");
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
await client.query("ROLLBACK").catch(() => undefined);
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
client.release();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
async ensureOwner(record) {
|
|
75
|
+
await this.pool.query(`INSERT INTO asm_agent.owners (id, handle) VALUES ($1, $2)
|
|
76
|
+
ON CONFLICT (id) DO UPDATE SET handle = EXCLUDED.handle, updated_at = clock_timestamp()`, [record.id, record.handle]);
|
|
77
|
+
}
|
|
78
|
+
async ensureProject(record) {
|
|
79
|
+
await this.pool.query(`INSERT INTO asm_agent.projects (id, owner_id, key, root_path) VALUES ($1, $2, $3, $4)
|
|
80
|
+
ON CONFLICT (id) DO UPDATE SET key = EXCLUDED.key, root_path = EXCLUDED.root_path,
|
|
81
|
+
updated_at = clock_timestamp()`, [record.id, record.ownerId, record.key, record.rootPath]);
|
|
82
|
+
}
|
|
83
|
+
async createSession(input) {
|
|
84
|
+
const result = await this.pool.query(`INSERT INTO asm_agent.sessions (id, project_id, parent_session_id, cwd, name, rlm_depth)
|
|
85
|
+
VALUES ($1, $2, $3, $4, $5, $6)
|
|
86
|
+
ON CONFLICT (id) DO UPDATE SET updated_at = clock_timestamp()
|
|
87
|
+
RETURNING *`, [input.id, input.projectId, input.parentSessionId ?? null, input.cwd, input.name ?? null, input.rlmDepth ?? 0]);
|
|
88
|
+
return sessionFromRow(requiredRow(result, "create session"));
|
|
89
|
+
}
|
|
90
|
+
async getSession(id) {
|
|
91
|
+
const result = await this.pool.query("SELECT * FROM asm_agent.sessions WHERE id = $1", [id]);
|
|
92
|
+
return result.rows[0] ? sessionFromRow(result.rows[0]) : undefined;
|
|
93
|
+
}
|
|
94
|
+
async updateSessionStatus(id, status) {
|
|
95
|
+
const result = await this.pool.query("UPDATE asm_agent.sessions SET status = $2, updated_at = clock_timestamp() WHERE id = $1", [id, status]);
|
|
96
|
+
return (result.rowCount ?? 0) === 1;
|
|
97
|
+
}
|
|
98
|
+
async appendMessage(sessionId, message) {
|
|
99
|
+
return this.transaction(async (client) => {
|
|
100
|
+
const revisionResult = await client.query(`UPDATE asm_agent.sessions SET transcript_revision = transcript_revision + 1,
|
|
101
|
+
updated_at = clock_timestamp() WHERE id = $1 RETURNING transcript_revision`, [sessionId]);
|
|
102
|
+
const sequence = Number(requiredRow(revisionResult, "increment transcript revision").transcript_revision);
|
|
103
|
+
await client.query(`INSERT INTO asm_agent.agent_messages
|
|
104
|
+
(session_id, sequence, entry_id, parent_entry_id, entry_type, payload, created_at)
|
|
105
|
+
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7)`, [
|
|
106
|
+
sessionId,
|
|
107
|
+
sequence,
|
|
108
|
+
message.entryId,
|
|
109
|
+
message.parentEntryId ?? null,
|
|
110
|
+
message.entryType,
|
|
111
|
+
JSON.stringify(message.payload),
|
|
112
|
+
message.createdAt,
|
|
113
|
+
]);
|
|
114
|
+
return sequence;
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
async listMessages(sessionId, afterSequence = 0) {
|
|
118
|
+
const result = await this.pool.query(`SELECT sequence, entry_id, parent_entry_id, entry_type, payload, created_at
|
|
119
|
+
FROM asm_agent.agent_messages WHERE session_id = $1 AND sequence > $2 ORDER BY sequence`, [sessionId, afterSequence]);
|
|
120
|
+
return result.rows.map((row) => ({
|
|
121
|
+
sequence: Number(row.sequence),
|
|
122
|
+
entryId: row.entry_id,
|
|
123
|
+
parentEntryId: row.parent_entry_id ?? undefined,
|
|
124
|
+
entryType: row.entry_type,
|
|
125
|
+
payload: row.payload,
|
|
126
|
+
createdAt: row.created_at,
|
|
127
|
+
}));
|
|
128
|
+
}
|
|
129
|
+
async upsertGoal(goal) {
|
|
130
|
+
await this.pool.query(`INSERT INTO asm_agent.goals
|
|
131
|
+
(id, session_id, status, objective, token_budget, tokens_used, time_used_seconds,
|
|
132
|
+
continuations_used, last_reason, last_error)
|
|
133
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
|
|
134
|
+
ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, objective = EXCLUDED.objective,
|
|
135
|
+
token_budget = EXCLUDED.token_budget, tokens_used = EXCLUDED.tokens_used,
|
|
136
|
+
time_used_seconds = EXCLUDED.time_used_seconds, continuations_used = EXCLUDED.continuations_used,
|
|
137
|
+
last_reason = EXCLUDED.last_reason, last_error = EXCLUDED.last_error, updated_at = clock_timestamp()`, [
|
|
138
|
+
goal.id,
|
|
139
|
+
goal.sessionId,
|
|
140
|
+
goal.status,
|
|
141
|
+
goal.objective,
|
|
142
|
+
goal.tokenBudget ?? null,
|
|
143
|
+
goal.tokensUsed,
|
|
144
|
+
goal.timeUsedSeconds,
|
|
145
|
+
goal.continuationsUsed,
|
|
146
|
+
goal.lastReason ?? null,
|
|
147
|
+
goal.lastError ?? null,
|
|
148
|
+
]);
|
|
149
|
+
}
|
|
150
|
+
async getCurrentGoal(sessionId) {
|
|
151
|
+
const result = await this.pool.query(`SELECT * FROM asm_agent.goals WHERE session_id = $1
|
|
152
|
+
ORDER BY updated_at DESC, id DESC LIMIT 1`, [sessionId]);
|
|
153
|
+
const row = result.rows[0];
|
|
154
|
+
return row
|
|
155
|
+
? {
|
|
156
|
+
id: row.id,
|
|
157
|
+
sessionId: row.session_id,
|
|
158
|
+
status: row.status,
|
|
159
|
+
objective: row.objective,
|
|
160
|
+
tokenBudget: row.token_budget === null ? undefined : Number(row.token_budget),
|
|
161
|
+
tokensUsed: Number(row.tokens_used),
|
|
162
|
+
timeUsedSeconds: Number(row.time_used_seconds),
|
|
163
|
+
continuationsUsed: row.continuations_used,
|
|
164
|
+
lastReason: row.last_reason ?? undefined,
|
|
165
|
+
lastError: row.last_error ?? undefined,
|
|
166
|
+
}
|
|
167
|
+
: undefined;
|
|
168
|
+
}
|
|
169
|
+
async upsertSchedule(schedule) {
|
|
170
|
+
await this.pool.query(`INSERT INTO asm_agent.schedules
|
|
171
|
+
(id, session_id, status, source, schedule_kind, expression, interval_ms, prompt,
|
|
172
|
+
metadata, next_run_at, last_run_at, run_count)
|
|
173
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb,$10,$11,$12)
|
|
174
|
+
ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, source = EXCLUDED.source,
|
|
175
|
+
schedule_kind = EXCLUDED.schedule_kind, expression = EXCLUDED.expression,
|
|
176
|
+
interval_ms = EXCLUDED.interval_ms, prompt = EXCLUDED.prompt, metadata = EXCLUDED.metadata,
|
|
177
|
+
next_run_at = EXCLUDED.next_run_at, last_run_at = EXCLUDED.last_run_at,
|
|
178
|
+
run_count = EXCLUDED.run_count, updated_at = clock_timestamp()`, [
|
|
179
|
+
schedule.id,
|
|
180
|
+
schedule.sessionId,
|
|
181
|
+
schedule.status,
|
|
182
|
+
schedule.source,
|
|
183
|
+
schedule.scheduleKind,
|
|
184
|
+
schedule.expression,
|
|
185
|
+
schedule.intervalMs ?? null,
|
|
186
|
+
schedule.prompt,
|
|
187
|
+
JSON.stringify(schedule.metadata),
|
|
188
|
+
schedule.nextRunAt ?? null,
|
|
189
|
+
schedule.lastRunAt ?? null,
|
|
190
|
+
schedule.runCount,
|
|
191
|
+
]);
|
|
192
|
+
}
|
|
193
|
+
async listDueSchedules(now, limit = 100) {
|
|
194
|
+
const result = await this.pool.query(`SELECT * FROM asm_agent.schedules WHERE status = 'active' AND next_run_at <= $1
|
|
195
|
+
ORDER BY next_run_at, id LIMIT $2`, [now, limit]);
|
|
196
|
+
return result.rows.map(scheduleFromRow);
|
|
197
|
+
}
|
|
198
|
+
async acquireLease(input) {
|
|
199
|
+
if (!Number.isInteger(input.ttlMs) || input.ttlMs <= 0)
|
|
200
|
+
throw new Error("Lease ttlMs must be positive");
|
|
201
|
+
const result = await this.pool.query(`INSERT INTO asm_agent.leases (resource_type, resource_id, owner_id, expires_at)
|
|
202
|
+
VALUES ($1, $2, $3, clock_timestamp() + ($4 * interval '1 millisecond'))
|
|
203
|
+
ON CONFLICT (resource_type, resource_id) DO UPDATE SET
|
|
204
|
+
owner_id = EXCLUDED.owner_id,
|
|
205
|
+
fencing_token = nextval('asm_agent.lease_fencing_token_seq'),
|
|
206
|
+
acquired_at = clock_timestamp(), expires_at = EXCLUDED.expires_at
|
|
207
|
+
WHERE asm_agent.leases.expires_at <= clock_timestamp()
|
|
208
|
+
RETURNING *`, [input.resourceType, input.resourceId, input.ownerId, input.ttlMs]);
|
|
209
|
+
return result.rows[0] ? leaseFromRow(result.rows[0]) : undefined;
|
|
210
|
+
}
|
|
211
|
+
async renewLease(lease, ttlMs) {
|
|
212
|
+
const result = await this.pool.query(`UPDATE asm_agent.leases SET expires_at = clock_timestamp() + ($5 * interval '1 millisecond')
|
|
213
|
+
WHERE resource_type = $1 AND resource_id = $2 AND owner_id = $3 AND fencing_token = $4
|
|
214
|
+
AND expires_at > clock_timestamp()`, [lease.resourceType, lease.resourceId, lease.ownerId, lease.fencingToken, ttlMs]);
|
|
215
|
+
return (result.rowCount ?? 0) === 1;
|
|
216
|
+
}
|
|
217
|
+
async releaseLease(lease) {
|
|
218
|
+
const result = await this.pool.query(`DELETE FROM asm_agent.leases WHERE resource_type = $1 AND resource_id = $2
|
|
219
|
+
AND owner_id = $3 AND fencing_token = $4`, [lease.resourceType, lease.resourceId, lease.ownerId, lease.fencingToken]);
|
|
220
|
+
return (result.rowCount ?? 0) === 1;
|
|
221
|
+
}
|
|
222
|
+
async startIdempotent(input) {
|
|
223
|
+
return this.transaction(async (client) => {
|
|
224
|
+
const inserted = await client.query(`INSERT INTO asm_agent.idempotency_records (scope, key, request_hash, status, expires_at)
|
|
225
|
+
VALUES ($1, $2, $3, 'started', $4) ON CONFLICT DO NOTHING`, [input.scope, input.key, input.requestHash, input.expiresAt ?? null]);
|
|
226
|
+
if ((inserted.rowCount ?? 0) === 1)
|
|
227
|
+
return { status: "started" };
|
|
228
|
+
const existing = requiredRow(await client.query("SELECT request_hash, status, response FROM asm_agent.idempotency_records WHERE scope = $1 AND key = $2 FOR UPDATE", [input.scope, input.key]), "load idempotency record");
|
|
229
|
+
if (existing.request_hash !== input.requestHash)
|
|
230
|
+
throw new Error("Idempotency key reused with different request");
|
|
231
|
+
return existing.status === "completed"
|
|
232
|
+
? { status: "replayed", response: existing.response }
|
|
233
|
+
: { status: "in_progress" };
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
async completeIdempotent(scope, key, response) {
|
|
237
|
+
await this.pool.query(`UPDATE asm_agent.idempotency_records SET status = 'completed', response = $3::jsonb,
|
|
238
|
+
error = NULL, updated_at = clock_timestamp() WHERE scope = $1 AND key = $2`, [scope, key, JSON.stringify(response)]);
|
|
239
|
+
}
|
|
240
|
+
async enqueueOutbox(input) {
|
|
241
|
+
const id = randomUUID();
|
|
242
|
+
await this.pool.query(`INSERT INTO asm_agent.outbox_events
|
|
243
|
+
(id, topic, aggregate_type, aggregate_id, payload, headers)
|
|
244
|
+
VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb)`, [
|
|
245
|
+
id,
|
|
246
|
+
input.topic,
|
|
247
|
+
input.aggregateType,
|
|
248
|
+
input.aggregateId,
|
|
249
|
+
JSON.stringify(input.payload),
|
|
250
|
+
JSON.stringify(input.headers),
|
|
251
|
+
]);
|
|
252
|
+
return id;
|
|
253
|
+
}
|
|
254
|
+
async claimOutbox(workerId, limit = 100, lockMs = 30_000) {
|
|
255
|
+
return this.transaction(async (client) => {
|
|
256
|
+
const result = await client.query(`WITH candidates AS (
|
|
257
|
+
SELECT id FROM asm_agent.outbox_events
|
|
258
|
+
WHERE published_at IS NULL AND available_at <= clock_timestamp()
|
|
259
|
+
AND (locked_until IS NULL OR locked_until <= clock_timestamp())
|
|
260
|
+
ORDER BY available_at, id FOR UPDATE SKIP LOCKED LIMIT $1
|
|
261
|
+
)
|
|
262
|
+
UPDATE asm_agent.outbox_events event SET locked_by = $2,
|
|
263
|
+
locked_until = clock_timestamp() + ($3 * interval '1 millisecond'), attempts = attempts + 1
|
|
264
|
+
FROM candidates WHERE event.id = candidates.id RETURNING event.*`, [limit, workerId, lockMs]);
|
|
265
|
+
return result.rows.map(outboxFromRow);
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
async markOutboxPublished(id, workerId) {
|
|
269
|
+
const result = await this.pool.query(`UPDATE asm_agent.outbox_events SET published_at = clock_timestamp(), locked_by = NULL,
|
|
270
|
+
locked_until = NULL WHERE id = $1 AND locked_by = $2 AND published_at IS NULL`, [id, workerId]);
|
|
271
|
+
return (result.rowCount ?? 0) === 1;
|
|
272
|
+
}
|
|
273
|
+
async applyMigration(client, migration) {
|
|
274
|
+
let existing;
|
|
275
|
+
try {
|
|
276
|
+
existing = (await client.query("SELECT version, name, checksum FROM asm_agent.schema_migrations WHERE version = $1", [migration.version])).rows[0];
|
|
277
|
+
}
|
|
278
|
+
catch (error) {
|
|
279
|
+
if (error.code !== "42P01")
|
|
280
|
+
throw error;
|
|
281
|
+
}
|
|
282
|
+
if (existing) {
|
|
283
|
+
if (existing.checksum !== migration.checksum) {
|
|
284
|
+
throw new Error(`Migration checksum mismatch: ${migration.name}`);
|
|
285
|
+
}
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
await client.query("BEGIN");
|
|
289
|
+
try {
|
|
290
|
+
await client.query(migration.sql);
|
|
291
|
+
await client.query("INSERT INTO asm_agent.schema_migrations (version, name, checksum) VALUES ($1, $2, $3)", [
|
|
292
|
+
migration.version,
|
|
293
|
+
migration.name,
|
|
294
|
+
migration.checksum,
|
|
295
|
+
]);
|
|
296
|
+
await client.query("COMMIT");
|
|
297
|
+
}
|
|
298
|
+
catch (error) {
|
|
299
|
+
await client.query("ROLLBACK");
|
|
300
|
+
throw error;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
async function loadMigrations() {
|
|
305
|
+
const directory = new URL("../migrations/", import.meta.url);
|
|
306
|
+
const names = (await readdir(directory)).filter((name) => /^\d+_[a-z0-9_-]+\.sql$/.test(name)).sort();
|
|
307
|
+
return Promise.all(names.map(async (name) => {
|
|
308
|
+
const sql = await readFile(new URL(name, directory), "utf8");
|
|
309
|
+
return {
|
|
310
|
+
version: Number(name.slice(0, name.indexOf("_"))),
|
|
311
|
+
name,
|
|
312
|
+
checksum: createHash("sha256").update(sql).digest("hex"),
|
|
313
|
+
sql,
|
|
314
|
+
};
|
|
315
|
+
}));
|
|
316
|
+
}
|
|
317
|
+
function requiredRow(result, operation) {
|
|
318
|
+
const row = result.rows[0];
|
|
319
|
+
if (!row)
|
|
320
|
+
throw new Error(`PostgreSQL did not return a row for ${operation}`);
|
|
321
|
+
return row;
|
|
322
|
+
}
|
|
323
|
+
function sessionFromRow(row) {
|
|
324
|
+
return {
|
|
325
|
+
id: row.id,
|
|
326
|
+
projectId: row.project_id,
|
|
327
|
+
parentSessionId: row.parent_session_id ?? undefined,
|
|
328
|
+
status: row.status,
|
|
329
|
+
cwd: row.cwd,
|
|
330
|
+
name: row.name ?? undefined,
|
|
331
|
+
rlmDepth: row.rlm_depth,
|
|
332
|
+
transcriptRevision: Number(row.transcript_revision),
|
|
333
|
+
createdAt: row.created_at,
|
|
334
|
+
updatedAt: row.updated_at,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
function leaseFromRow(row) {
|
|
338
|
+
return {
|
|
339
|
+
resourceType: row.resource_type,
|
|
340
|
+
resourceId: row.resource_id,
|
|
341
|
+
ownerId: row.owner_id,
|
|
342
|
+
fencingToken: Number(row.fencing_token),
|
|
343
|
+
expiresAt: row.expires_at,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
function outboxFromRow(row) {
|
|
347
|
+
return {
|
|
348
|
+
id: row.id,
|
|
349
|
+
topic: row.topic,
|
|
350
|
+
aggregateType: row.aggregate_type,
|
|
351
|
+
aggregateId: row.aggregate_id,
|
|
352
|
+
payload: row.payload,
|
|
353
|
+
headers: row.headers,
|
|
354
|
+
attempts: row.attempts,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
function scheduleFromRow(row) {
|
|
358
|
+
return {
|
|
359
|
+
id: row.id,
|
|
360
|
+
sessionId: row.session_id,
|
|
361
|
+
status: row.status,
|
|
362
|
+
source: row.source,
|
|
363
|
+
scheduleKind: row.schedule_kind,
|
|
364
|
+
expression: row.expression,
|
|
365
|
+
intervalMs: row.interval_ms === null ? undefined : Number(row.interval_ms),
|
|
366
|
+
prompt: row.prompt,
|
|
367
|
+
metadata: row.metadata,
|
|
368
|
+
nextRunAt: row.next_run_at ?? undefined,
|
|
369
|
+
lastRunAt: row.last_run_at ?? undefined,
|
|
370
|
+
runCount: Number(row.run_count),
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
//# sourceMappingURL=database.js.map
|