@sixb/pg 0.1.20 → 0.1.21

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.
Files changed (43) hide show
  1. package/dist/agents/threads.d.ts +4 -0
  2. package/dist/agents/threads.d.ts.map +1 -1
  3. package/dist/auth-storage/sessions.d.ts +2 -1
  4. package/dist/auth-storage/sessions.d.ts.map +1 -1
  5. package/dist/index.d.ts +4 -0
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +959 -7
  8. package/dist/index.js.map +15 -13
  9. package/dist/migrations.d.ts.map +1 -1
  10. package/dist/ontology-storage/replacement-plans.d.ts.map +1 -1
  11. package/dist/ontology-storage/sources.d.ts +2 -1
  12. package/dist/ontology-storage/sources.d.ts.map +1 -1
  13. package/dist/pg-action-run-storage.d.ts +4 -0
  14. package/dist/pg-action-run-storage.d.ts.map +1 -1
  15. package/dist/pg-activity-storage.d.ts +22 -0
  16. package/dist/pg-activity-storage.d.ts.map +1 -0
  17. package/dist/pg-artifact-storage.d.ts +19 -0
  18. package/dist/pg-artifact-storage.d.ts.map +1 -0
  19. package/dist/pg-execution-storage.d.ts +2 -1
  20. package/dist/pg-execution-storage.d.ts.map +1 -1
  21. package/dist/pg-workflow-run-storage.d.ts +4 -0
  22. package/dist/pg-workflow-run-storage.d.ts.map +1 -1
  23. package/dist/share-grant-storage.d.ts +2 -1
  24. package/dist/share-grant-storage.d.ts.map +1 -1
  25. package/package.json +3 -3
  26. package/src/agents/threads.ts +23 -0
  27. package/src/auth-storage/sessions.ts +20 -0
  28. package/src/index.ts +10 -0
  29. package/src/migrations/063-artifacts.sql +67 -0
  30. package/src/migrations/064-drop-output-diagnostics.sql +14 -0
  31. package/src/migrations/065-plan-work-payload-statistics.sql +5 -0
  32. package/src/migrations/066-activity.sql +61 -0
  33. package/src/migrations/067-activity-timeline.sql +15 -0
  34. package/src/migrations/068-activity-interactions.sql +20 -0
  35. package/src/migrations.ts +18 -0
  36. package/src/ontology-storage/replacement-plans.ts +25 -7
  37. package/src/ontology-storage/sources.ts +37 -0
  38. package/src/pg-action-run-storage.ts +23 -0
  39. package/src/pg-activity-storage.ts +486 -0
  40. package/src/pg-artifact-storage.ts +325 -0
  41. package/src/pg-execution-storage.ts +52 -0
  42. package/src/pg-workflow-run-storage.ts +23 -0
  43. package/src/share-grant-storage.ts +21 -0
@@ -0,0 +1,325 @@
1
+ import {
2
+ artifactStorageErrors,
3
+ parseArtifactFrameGrantRow,
4
+ parseArtifactRow,
5
+ parseArtifactVersionRow,
6
+ } from "@sixb/core/internal/storage"
7
+ import type {
8
+ ArtifactFrameGrantRecord,
9
+ ArtifactRecord,
10
+ ArtifactStorage,
11
+ ArtifactVersionInput,
12
+ ArtifactVersionRecord,
13
+ CreateArtifactInput,
14
+ DeleteArtifactInput,
15
+ DeleteArtifactVersionsByOriginInput,
16
+ DeleteExpiredArtifactFrameGrantsInput,
17
+ FindArtifactFrameGrantInput,
18
+ GetArtifactInput,
19
+ GetArtifactVersionInput,
20
+ ListArtifactsInput,
21
+ ListArtifactsResult,
22
+ ListArtifactVersionsInput,
23
+ ListArtifactVersionsResult,
24
+ ListReferencingVersionsInput,
25
+ PublishArtifactVersionInput,
26
+ PublishedArtifactVersion,
27
+ } from "@sixb/core/storage"
28
+ import type { SQLClient, SqlParameter } from "./pg-client"
29
+ import { type PgStoreClient, runPgTransaction } from "./transactions"
30
+
31
+ interface PgRow {
32
+ readonly [column: string]: unknown
33
+ }
34
+
35
+ // Quoted aliases give the shared row parsers their camelCase keys; an unquoted alias would fold to
36
+ // lower case.
37
+ const ARTIFACT_COLUMNS = `
38
+ project_id AS "projectId", id, kind, title,
39
+ owner_principal_type AS "ownerPrincipalType", owner_principal_id AS "ownerPrincipalId",
40
+ latest_version AS "latestVersion", created_at AS "createdAt", updated_at AS "updatedAt"
41
+ `
42
+
43
+ const VERSION_COLUMNS = `
44
+ project_id AS "projectId", artifact_id AS "artifactId", version, title, entry, files, outline,
45
+ refs, author_principal_type AS "authorPrincipalType", author_principal_id AS "authorPrincipalId",
46
+ origin_thread_id AS "originThreadId", origin_run_id AS "originRunId", created_at AS "createdAt"
47
+ `
48
+
49
+ const FRAME_GRANT_COLUMNS = `
50
+ project_id AS "projectId", token_hash AS "tokenHash", artifact_id AS "artifactId", version,
51
+ targets, created_at AS "createdAt", expires_at AS "expiresAt"
52
+ `
53
+
54
+ export class PgArtifactStorage implements ArtifactStorage {
55
+ constructor(private readonly sql: PgStoreClient) {}
56
+
57
+ async create(input: CreateArtifactInput): Promise<PublishedArtifactVersion> {
58
+ return runPgTransaction(this.sql, async (tx) => {
59
+ const [artifact] = await tx<PgRow[]>`
60
+ INSERT INTO artifacts (
61
+ project_id, id, kind, title, owner_principal_type, owner_principal_id,
62
+ latest_version, next_version, created_at, updated_at
63
+ ) VALUES (
64
+ ${input.projectId}, ${input.id}, ${input.kind}, ${input.version.title},
65
+ ${input.owner.type}, ${input.owner.id}, 1, 2,
66
+ ${input.version.createdAt}, ${input.version.createdAt}
67
+ )
68
+ ON CONFLICT (project_id, id) DO NOTHING
69
+ RETURNING ${tx.unsafe(ARTIFACT_COLUMNS)}
70
+ `
71
+ if (!artifact) throw artifactStorageErrors.exists(input.id)
72
+ return {
73
+ artifact: parseArtifactRow(artifact),
74
+ version: await insertVersion(tx, input.projectId, input.id, 1, input.version),
75
+ }
76
+ })
77
+ }
78
+
79
+ async publish(input: PublishArtifactVersionInput): Promise<PublishedArtifactVersion> {
80
+ return runPgTransaction(this.sql, async (tx) => {
81
+ // The row lock serializes concurrent publishes of one artifact.
82
+ const [current] = await tx<PgRow[]>`
83
+ SELECT kind, next_version FROM artifacts
84
+ WHERE project_id = ${input.projectId} AND id = ${input.artifactId}
85
+ FOR UPDATE
86
+ `
87
+ if (!current) throw artifactStorageErrors.notFound(input.artifactId)
88
+ const kind = current.kind as ArtifactRecord["kind"]
89
+ if (kind !== input.kind) {
90
+ throw artifactStorageErrors.kindMismatch(input.artifactId, kind, input.kind)
91
+ }
92
+ const version = await insertVersion(
93
+ tx,
94
+ input.projectId,
95
+ input.artifactId,
96
+ Number(current.next_version),
97
+ input.version
98
+ )
99
+ const [artifact] = await tx<PgRow[]>`
100
+ UPDATE artifacts
101
+ SET title = ${input.version.title}, latest_version = ${version.version},
102
+ next_version = ${version.version + 1}, updated_at = ${input.version.createdAt}
103
+ WHERE project_id = ${input.projectId} AND id = ${input.artifactId}
104
+ RETURNING ${tx.unsafe(ARTIFACT_COLUMNS)}
105
+ `
106
+ return { artifact: parseArtifactRow(artifact), version }
107
+ })
108
+ }
109
+
110
+ async get(input: GetArtifactInput): Promise<ArtifactRecord | null> {
111
+ const [row] = await this.sql<PgRow[]>`
112
+ SELECT ${this.sql.unsafe(ARTIFACT_COLUMNS)} FROM artifacts
113
+ WHERE project_id = ${input.projectId} AND id = ${input.id}
114
+ `
115
+ return row ? parseArtifactRow(row) : null
116
+ }
117
+
118
+ async list(input: ListArtifactsInput): Promise<ListArtifactsResult> {
119
+ const where = ["a.project_id = $1"]
120
+ const params: SqlParameter[] = [input.projectId]
121
+ if (input.owner) {
122
+ where.push(`a.owner_principal_type = $${params.length + 1}`)
123
+ params.push(input.owner.type)
124
+ where.push(`a.owner_principal_id = $${params.length + 1}`)
125
+ params.push(input.owner.id)
126
+ }
127
+ if (input.originThreadId !== undefined) {
128
+ where.push(
129
+ `EXISTS (
130
+ SELECT 1 FROM artifact_versions v
131
+ WHERE v.project_id = a.project_id AND v.artifact_id = a.id
132
+ AND v.origin_thread_id = $${params.length + 1}
133
+ )`
134
+ )
135
+ params.push(input.originThreadId)
136
+ }
137
+ if (input.kind !== undefined) {
138
+ where.push(`a.kind = $${params.length + 1}`)
139
+ params.push(input.kind)
140
+ }
141
+ const clause = where.join(" AND ")
142
+ const [counted] = await this.sql.unsafe<{ total: number }[]>(
143
+ `SELECT COUNT(*)::int AS total FROM artifacts a WHERE ${clause}`,
144
+ params
145
+ )
146
+ const offset = input.offset ?? 0
147
+ const rows = await this.sql.unsafe<PgRow[]>(
148
+ `SELECT ${ARTIFACT_COLUMNS} FROM artifacts a WHERE ${clause}
149
+ ORDER BY a.updated_at DESC, a.id ASC
150
+ LIMIT $${params.length + 1} OFFSET $${params.length + 2}`,
151
+ [...params, input.limit ?? null, offset]
152
+ )
153
+ const total = counted?.total ?? 0
154
+ return { artifacts: rows.map(parseArtifactRow), hasMore: offset + rows.length < total, total }
155
+ }
156
+
157
+ async getVersion(input: GetArtifactVersionInput): Promise<ArtifactVersionRecord | null> {
158
+ const [row] = await this.sql<PgRow[]>`
159
+ SELECT ${this.sql.unsafe(VERSION_COLUMNS)} FROM artifact_versions
160
+ WHERE project_id = ${input.projectId} AND artifact_id = ${input.artifactId}
161
+ AND version = ${input.version}
162
+ `
163
+ return row ? parseArtifactVersionRow(row) : null
164
+ }
165
+
166
+ async listVersions(input: ListArtifactVersionsInput): Promise<ListArtifactVersionsResult> {
167
+ const [counted] = await this.sql<{ total: number }[]>`
168
+ SELECT COUNT(*)::int AS total FROM artifact_versions
169
+ WHERE project_id = ${input.projectId} AND artifact_id = ${input.artifactId}
170
+ `
171
+ const offset = input.offset ?? 0
172
+ const rows = await this.sql<PgRow[]>`
173
+ SELECT ${this.sql.unsafe(VERSION_COLUMNS)} FROM artifact_versions
174
+ WHERE project_id = ${input.projectId} AND artifact_id = ${input.artifactId}
175
+ ORDER BY version DESC
176
+ LIMIT ${input.limit ?? null} OFFSET ${offset}
177
+ `
178
+ const total = counted?.total ?? 0
179
+ return {
180
+ versions: rows.map(parseArtifactVersionRow),
181
+ hasMore: offset + rows.length < total,
182
+ total,
183
+ }
184
+ }
185
+
186
+ async listReferencingVersions(
187
+ input: ListReferencingVersionsInput
188
+ ): Promise<ArtifactVersionRecord[]> {
189
+ // An address names the artifact when the id is followed by nothing, a version, a file, or a
190
+ // part.
191
+ const prefix = `artifact:${input.artifactId}`
192
+ const rows = await this.sql<PgRow[]>`
193
+ SELECT ${this.sql.unsafe(VERSION_COLUMNS)} FROM artifact_versions v
194
+ WHERE project_id = ${input.projectId} AND (artifact_id, version) IN (
195
+ SELECT id, latest_version FROM artifacts WHERE project_id = ${input.projectId}
196
+ ) AND EXISTS (
197
+ SELECT 1 FROM jsonb_array_elements(v.refs) r
198
+ WHERE left(r ->> 'address', ${prefix.length}::int) = ${prefix}
199
+ AND substr(r ->> 'address', ${prefix.length + 1}::int, 1) IN ('', '@', '/', '#')
200
+ )
201
+ ORDER BY artifact_id
202
+ `
203
+ return rows.map(parseArtifactVersionRow)
204
+ }
205
+
206
+ async createFrameGrant(record: ArtifactFrameGrantRecord): Promise<void> {
207
+ const rows = await this.sql`
208
+ INSERT INTO artifact_frame_grants
209
+ (project_id, token_hash, artifact_id, version, targets, created_at, expires_at)
210
+ SELECT ${record.projectId}, ${record.tokenHash}, ${record.artifactId}, ${record.version},
211
+ ${JSON.stringify(record.targets)}::text::jsonb, ${record.createdAt},
212
+ ${record.expiresAt}
213
+ WHERE EXISTS (
214
+ SELECT 1 FROM artifacts WHERE project_id = ${record.projectId} AND id = ${record.artifactId}
215
+ )
216
+ RETURNING token_hash
217
+ `
218
+ if (rows.length === 0) throw artifactStorageErrors.notFound(record.artifactId)
219
+ }
220
+
221
+ async findFrameGrant(
222
+ input: FindArtifactFrameGrantInput
223
+ ): Promise<ArtifactFrameGrantRecord | null> {
224
+ const [row] = await this.sql<PgRow[]>`
225
+ SELECT ${this.sql.unsafe(FRAME_GRANT_COLUMNS)} FROM artifact_frame_grants
226
+ WHERE project_id = ${input.projectId} AND token_hash = ${input.tokenHash}
227
+ `
228
+ return row ? parseArtifactFrameGrantRow(row) : null
229
+ }
230
+
231
+ async deleteExpiredFrameGrants(input: DeleteExpiredArtifactFrameGrantsInput): Promise<number> {
232
+ const rows = await this.sql`
233
+ DELETE FROM artifact_frame_grants
234
+ WHERE (project_id, token_hash) IN (
235
+ SELECT project_id, token_hash FROM artifact_frame_grants
236
+ WHERE project_id = ${input.projectId} AND expires_at < ${input.expiredBefore}
237
+ ORDER BY expires_at LIMIT ${input.limit}
238
+ )
239
+ RETURNING token_hash
240
+ `
241
+ return rows.length
242
+ }
243
+
244
+ async delete(input: DeleteArtifactInput): Promise<boolean> {
245
+ // Versions and frame grants go with the artifact through ON DELETE CASCADE.
246
+ const rows = await this.sql`
247
+ DELETE FROM artifacts WHERE project_id = ${input.projectId} AND id = ${input.id}
248
+ RETURNING id
249
+ `
250
+ return rows.length > 0
251
+ }
252
+
253
+ async deleteVersionsByOrigin(input: DeleteArtifactVersionsByOriginInput): Promise<number> {
254
+ return runPgTransaction(this.sql, async (tx) => {
255
+ // Lock the run's artifacts first, in id order so two of these never deadlock. `publish`
256
+ // holds the same lock while it adds a version, and under READ COMMITTED each statement
257
+ // below then reads a snapshot taken after any publish that held it committed. Without the
258
+ // lock they would wait on a publish's row lock and then apply what they read before it: the
259
+ // UPDATE would point `latest_version` back past the new version, and the DELETE would drop
260
+ // an artifact whose versions were all the run's, cascading the new version with it.
261
+ const locked = await tx<{ id: string }[]>`
262
+ SELECT id FROM artifacts
263
+ WHERE project_id = ${input.projectId} AND id IN (
264
+ SELECT artifact_id FROM artifact_versions
265
+ WHERE project_id = ${input.projectId} AND origin_run_id = ${input.runId}
266
+ )
267
+ ORDER BY id
268
+ FOR UPDATE
269
+ `
270
+ const affected = locked.map((row) => row.id)
271
+ if (affected.length === 0) return 0
272
+ const deleted = await tx`
273
+ DELETE FROM artifact_versions
274
+ WHERE project_id = ${input.projectId} AND origin_run_id = ${input.runId}
275
+ AND artifact_id IN ${tx(affected)}
276
+ RETURNING version
277
+ `
278
+ // An artifact left without versions goes; the rest point at their newest remaining one.
279
+ await tx`
280
+ DELETE FROM artifacts a
281
+ WHERE a.project_id = ${input.projectId} AND a.id IN ${tx(affected)}
282
+ AND NOT EXISTS (
283
+ SELECT 1 FROM artifact_versions v
284
+ WHERE v.project_id = a.project_id AND v.artifact_id = a.id
285
+ )
286
+ `
287
+ await tx`
288
+ UPDATE artifacts a
289
+ SET latest_version = latest.version, title = latest.title, updated_at = latest.created_at
290
+ FROM (
291
+ SELECT DISTINCT ON (artifact_id) artifact_id, version, title, created_at
292
+ FROM artifact_versions
293
+ WHERE project_id = ${input.projectId} AND artifact_id IN ${tx(affected)}
294
+ ORDER BY artifact_id, version DESC
295
+ ) latest
296
+ WHERE a.project_id = ${input.projectId} AND a.id = latest.artifact_id
297
+ `
298
+ return deleted.length
299
+ })
300
+ }
301
+ }
302
+
303
+ async function insertVersion(
304
+ sql: SQLClient,
305
+ projectId: string,
306
+ artifactId: string,
307
+ version: number,
308
+ input: ArtifactVersionInput
309
+ ): Promise<ArtifactVersionRecord> {
310
+ const [row] = await sql<PgRow[]>`
311
+ INSERT INTO artifact_versions (
312
+ project_id, artifact_id, version, title, entry, files, outline, refs,
313
+ author_principal_type, author_principal_id, origin_thread_id, origin_run_id, created_at
314
+ ) VALUES (
315
+ ${projectId}, ${artifactId}, ${version}, ${input.title}, ${input.entry},
316
+ ${JSON.stringify(input.files)}::text::jsonb,
317
+ ${JSON.stringify(input.outline)}::text::jsonb,
318
+ ${JSON.stringify(input.references)}::text::jsonb,
319
+ ${input.author.type}, ${input.author.id},
320
+ ${input.origin?.threadId ?? null}, ${input.origin?.runId ?? null}, ${input.createdAt}
321
+ )
322
+ RETURNING ${sql.unsafe(VERSION_COLUMNS)}
323
+ `
324
+ return parseArtifactVersionRow(row)
325
+ }
@@ -1,4 +1,5 @@
1
1
  import {
2
+ compareExecutionCreation,
2
3
  type ExecutionStorageRow,
3
4
  executionRecordFromStorageRow,
4
5
  executionRecordToStorageRow,
@@ -8,6 +9,8 @@ import type {
8
9
  CreateExecutionInput,
9
10
  ExecutionRecord,
10
11
  ExecutionStorage,
12
+ ListSessionExecutionsInput,
13
+ SessionExecutions,
11
14
  ShareSessionStorage,
12
15
  } from "@sixb/core/storage"
13
16
  import { ExecutionStorageError } from "@sixb/core/storage"
@@ -111,6 +114,55 @@ export class PgExecutionStorage implements ExecutionStorage {
111
114
  `
112
115
  return row ? pgExecutionRecord(row) : null
113
116
  }
117
+
118
+ async listBySession(input: ListSessionExecutionsInput): Promise<SessionExecutions> {
119
+ const session =
120
+ input.session.type === "user"
121
+ ? this.sql`authority_session_id = ${input.session.id}`
122
+ : this.sql`authority_delegation_session_id = ${input.session.id}`
123
+
124
+ // Each iteration of a recursive query reads the rows the previous one produced, so the walk goes
125
+ // level by level, roots first; with no ORDER BY outside, PostgreSQL evaluates only the rows the
126
+ // limit returns. Within the last level reached, which rows come first is unspecified.
127
+ const rows = await this.sql<(PgExecutionRow & { readonly depth: number })[]>`
128
+ WITH RECURSIVE tree AS (
129
+ (
130
+ SELECT *, 0 AS depth
131
+ FROM executions
132
+ WHERE project_id = ${input.projectId}
133
+ AND ${session}
134
+ AND parent_execution_id IS NULL
135
+ AND created_at >= ${input.from}
136
+ AND created_at < ${input.to}
137
+ ORDER BY created_at, id
138
+ LIMIT ${input.limit + 1}
139
+ )
140
+ UNION ALL
141
+ SELECT child.*, tree.depth + 1
142
+ FROM tree
143
+ JOIN executions child
144
+ ON child.project_id = tree.project_id
145
+ AND child.parent_execution_id = tree.id
146
+ )
147
+ SELECT * FROM tree
148
+ LIMIT ${input.limit + 1}
149
+ `
150
+
151
+ // Keep the first `limit` by depth, then oldest first, as the contract keeps them.
152
+ const records = rows
153
+ .map((row) => ({ depth: row.depth, record: pgExecutionRecord(row) }))
154
+ .sort(
155
+ (left, right) =>
156
+ left.depth - right.depth || compareExecutionCreation(left.record, right.record)
157
+ )
158
+ .slice(0, input.limit)
159
+ .map(({ record }) => record)
160
+
161
+ return {
162
+ executions: records.sort(compareExecutionCreation),
163
+ truncated: rows.length > input.limit,
164
+ }
165
+ }
114
166
  }
115
167
 
116
168
  /** An `executions` row as its record, for any store that reads executions alongside its own rows. */
@@ -314,6 +314,29 @@ export class PgWorkflowRunStorage implements WorkflowRunStorage {
314
314
  return row ? rowToWorkflowRunRecord(row) : null
315
315
  }
316
316
 
317
+ async getByExecutionIds(params: {
318
+ readonly projectId: string
319
+ readonly executionIds: readonly string[]
320
+ }): Promise<readonly WorkflowRunRecord[]> {
321
+ const executionIds = [...new Set(params.executionIds)]
322
+ if (executionIds.length === 0) {
323
+ return []
324
+ }
325
+
326
+ const rows = await this.sql<WorkflowRunDatabaseRow[]>`
327
+ SELECT *
328
+ FROM workflow_runs
329
+ WHERE project_id = ${params.projectId}
330
+ AND execution_id = ANY(${this.sql.array(executionIds)}::text[])
331
+ `
332
+
333
+ const byExecution = new Map(rows.map((row) => [row.execution_id, row]))
334
+ return executionIds.flatMap((executionId) => {
335
+ const row = byExecution.get(executionId)
336
+ return row ? [rowToWorkflowRunRecord(row)] : []
337
+ })
338
+ }
339
+
317
340
  async list(input: ListWorkflowRunsInput): Promise<ListWorkflowRunsResult> {
318
341
  if (hasEmptyStatuses(input) || input.workflowIds?.length === 0) {
319
342
  return {
@@ -8,6 +8,7 @@ import {
8
8
  import type {
9
9
  CreateShareGrantInput,
10
10
  GetShareGrantByIdInput,
11
+ GetShareGrantsByIdsInput,
11
12
  ListShareGrantsInput,
12
13
  ListShareGrantsResult,
13
14
  RevokeShareGrantInput,
@@ -85,6 +86,26 @@ export class PgShareGrantStorage implements ShareGrantStorage {
85
86
  return row ? recordFromRow(row) : null
86
87
  }
87
88
 
89
+ async getByIds(input: GetShareGrantsByIdsInput): Promise<readonly ShareGrantRecord[]> {
90
+ const ids = [...new Set(input.ids)]
91
+ if (ids.length === 0) {
92
+ return []
93
+ }
94
+
95
+ const rows = await this.sql<PgShareGrantRow[]>`
96
+ SELECT *
97
+ FROM share_grants
98
+ WHERE project_id = ${input.projectId}
99
+ AND id = ANY(${this.sql.array(ids)}::text[])
100
+ `
101
+
102
+ const byId = new Map(rows.map((row) => [row.id, row]))
103
+ return ids.flatMap((id) => {
104
+ const row = byId.get(id)
105
+ return row ? [recordFromRow(row)] : []
106
+ })
107
+ }
108
+
88
109
  async list(input: ListShareGrantsInput): Promise<ListShareGrantsResult> {
89
110
  const normalized = normalizeListShareGrantsInput(input)
90
111
  const where = ["project_id = $1"]