@sixb/pg 0.1.20 → 0.1.22

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 (139) hide show
  1. package/README.md +1 -0
  2. package/dist/agents/agent-storage.d.ts +8 -2
  3. package/dist/agents/agent-storage.d.ts.map +1 -1
  4. package/dist/agents/approvals.d.ts +48 -0
  5. package/dist/agents/approvals.d.ts.map +1 -0
  6. package/dist/agents/checkpoints.d.ts +1 -0
  7. package/dist/agents/checkpoints.d.ts.map +1 -1
  8. package/dist/agents/index.d.ts +1 -0
  9. package/dist/agents/index.d.ts.map +1 -1
  10. package/dist/agents/memory.d.ts +19 -0
  11. package/dist/agents/memory.d.ts.map +1 -0
  12. package/dist/agents/messages.d.ts.map +1 -1
  13. package/dist/agents/rows.d.ts +15 -1
  14. package/dist/agents/rows.d.ts.map +1 -1
  15. package/dist/agents/runs.d.ts +3 -1
  16. package/dist/agents/runs.d.ts.map +1 -1
  17. package/dist/agents/settings.d.ts +29 -0
  18. package/dist/agents/settings.d.ts.map +1 -0
  19. package/dist/agents/threads.d.ts +10 -1
  20. package/dist/agents/threads.d.ts.map +1 -1
  21. package/dist/ai-cost-groups.d.ts.map +1 -1
  22. package/dist/auth-storage/auth-storage.d.ts +3 -2
  23. package/dist/auth-storage/auth-storage.d.ts.map +1 -1
  24. package/dist/auth-storage/sessions.d.ts +2 -1
  25. package/dist/auth-storage/sessions.d.ts.map +1 -1
  26. package/dist/auth-storage/users.d.ts +8 -4
  27. package/dist/auth-storage/users.d.ts.map +1 -1
  28. package/dist/index.d.ts +4 -0
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +6001 -2923
  31. package/dist/index.js.map +45 -37
  32. package/dist/migrations.d.ts.map +1 -1
  33. package/dist/objects/conditional-reader.d.ts +17 -0
  34. package/dist/objects/conditional-reader.d.ts.map +1 -0
  35. package/dist/objects/query-compiler.d.ts +27 -1
  36. package/dist/objects/query-compiler.d.ts.map +1 -1
  37. package/dist/objects/query-indexes.d.ts.map +1 -1
  38. package/dist/objects/read-scope.d.ts +10 -1
  39. package/dist/objects/read-scope.d.ts.map +1 -1
  40. package/dist/objects/reader.d.ts +2 -0
  41. package/dist/objects/reader.d.ts.map +1 -1
  42. package/dist/objects/scoped-reader.d.ts +12 -0
  43. package/dist/objects/scoped-reader.d.ts.map +1 -0
  44. package/dist/objects/selected-reader.d.ts +1 -0
  45. package/dist/objects/selected-reader.d.ts.map +1 -1
  46. package/dist/objects/storage.d.ts +7 -1
  47. package/dist/objects/storage.d.ts.map +1 -1
  48. package/dist/ontology-storage/index.d.ts +2 -0
  49. package/dist/ontology-storage/index.d.ts.map +1 -1
  50. package/dist/ontology-storage/materialization-session.d.ts +6 -0
  51. package/dist/ontology-storage/materialization-session.d.ts.map +1 -1
  52. package/dist/ontology-storage/materialization-writer.d.ts +15 -0
  53. package/dist/ontology-storage/materialization-writer.d.ts.map +1 -1
  54. package/dist/ontology-storage/materializations.d.ts.map +1 -1
  55. package/dist/ontology-storage/replacement-plans.d.ts.map +1 -1
  56. package/dist/ontology-storage/sources.d.ts +2 -1
  57. package/dist/ontology-storage/sources.d.ts.map +1 -1
  58. package/dist/ontology-storage/unique-values.d.ts +60 -0
  59. package/dist/ontology-storage/unique-values.d.ts.map +1 -0
  60. package/dist/pg-action-run-storage.d.ts +4 -0
  61. package/dist/pg-action-run-storage.d.ts.map +1 -1
  62. package/dist/pg-activity-storage.d.ts +22 -0
  63. package/dist/pg-activity-storage.d.ts.map +1 -0
  64. package/dist/pg-ai-cost-storage.d.ts.map +1 -1
  65. package/dist/pg-ai-usage-storage.d.ts +8 -1
  66. package/dist/pg-ai-usage-storage.d.ts.map +1 -1
  67. package/dist/pg-artifact-storage.d.ts +26 -0
  68. package/dist/pg-artifact-storage.d.ts.map +1 -0
  69. package/dist/pg-execution-storage.d.ts +3 -1
  70. package/dist/pg-execution-storage.d.ts.map +1 -1
  71. package/dist/pg-workflow-run-storage.d.ts +4 -0
  72. package/dist/pg-workflow-run-storage.d.ts.map +1 -1
  73. package/dist/share-grant-storage.d.ts +2 -1
  74. package/dist/share-grant-storage.d.ts.map +1 -1
  75. package/dist/storage-errors.d.ts +2 -0
  76. package/dist/storage-errors.d.ts.map +1 -1
  77. package/package.json +3 -3
  78. package/src/agents/agent-storage.ts +11 -2
  79. package/src/agents/approvals.ts +400 -0
  80. package/src/agents/checkpoints.ts +8 -4
  81. package/src/agents/index.ts +1 -0
  82. package/src/agents/memory.ts +175 -0
  83. package/src/agents/messages.ts +14 -7
  84. package/src/agents/rows.ts +46 -1
  85. package/src/agents/runs.ts +154 -22
  86. package/src/agents/settings.ts +105 -0
  87. package/src/agents/threads.ts +379 -3
  88. package/src/ai-cost-groups.ts +11 -1
  89. package/src/auth-storage/auth-storage.ts +45 -13
  90. package/src/auth-storage/sessions.ts +20 -0
  91. package/src/auth-storage/users.ts +105 -43
  92. package/src/index.ts +10 -0
  93. package/src/migrations/063-artifacts.sql +67 -0
  94. package/src/migrations/064-drop-output-diagnostics.sql +14 -0
  95. package/src/migrations/065-plan-work-payload-statistics.sql +5 -0
  96. package/src/migrations/066-activity.sql +61 -0
  97. package/src/migrations/067-activity-timeline.sql +15 -0
  98. package/src/migrations/068-activity-interactions.sql +20 -0
  99. package/src/migrations/069-artifact-kinds.sql +4 -0
  100. package/src/migrations/070-artifact-state.sql +16 -0
  101. package/src/migrations/071-agent-thread-participants.sql +16 -0
  102. package/src/migrations/072-forked-context-checkpoints.sql +23 -0
  103. package/src/migrations/073-unique-values.sql +25 -0
  104. package/src/migrations/074-generated-media-usage.sql +21 -0
  105. package/src/migrations/075-model-call-files.sql +11 -0
  106. package/src/migrations/076-agent-memory.sql +33 -0
  107. package/src/migrations/077-artifact-threads.sql +21 -0
  108. package/src/migrations/078-agent-approvals.sql +58 -0
  109. package/src/migrations/079-agent-approval-resume.sql +20 -0
  110. package/src/migrations/080-subagent-threads.sql +145 -0
  111. package/src/migrations/081-subagent-questions.sql +6 -0
  112. package/src/migrations.ts +52 -0
  113. package/src/objects/conditional-reader.ts +44 -0
  114. package/src/objects/prepare-queries.ts +1 -1
  115. package/src/objects/query-compiler.ts +96 -33
  116. package/src/objects/query-indexes.ts +29 -10
  117. package/src/objects/read-scope.ts +140 -25
  118. package/src/objects/reader.ts +50 -6
  119. package/src/objects/scoped-reader.ts +101 -0
  120. package/src/objects/selected-reader.ts +16 -76
  121. package/src/objects/storage.ts +24 -5
  122. package/src/objects/text-count-index.ts +1 -0
  123. package/src/ontology-storage/index.ts +3 -0
  124. package/src/ontology-storage/materialization-session.ts +8 -0
  125. package/src/ontology-storage/materialization-writer.ts +76 -1
  126. package/src/ontology-storage/materializations.ts +20 -1
  127. package/src/ontology-storage/replacement-plans.ts +25 -7
  128. package/src/ontology-storage/sources.ts +37 -0
  129. package/src/ontology-storage/unique-values.ts +274 -0
  130. package/src/pg-action-run-storage.ts +23 -0
  131. package/src/pg-activity-storage.ts +486 -0
  132. package/src/pg-ai-cost-storage.ts +62 -12
  133. package/src/pg-ai-usage-storage.ts +90 -1
  134. package/src/pg-artifact-storage.ts +521 -0
  135. package/src/pg-execution-storage.ts +83 -0
  136. package/src/pg-workflow-run-storage.ts +23 -0
  137. package/src/share-grant-storage.ts +21 -0
  138. package/src/storage-errors.ts +7 -0
  139. package/src/transactions.ts +1 -1
@@ -1,14 +1,23 @@
1
- import { assertAiUsageExecutionId, normalizeAiModelCallRecord } from "@sixb/core/internal/storage"
1
+ import type { FileRef } from "@sixb/core/blob-storage/server"
2
+ import {
3
+ assertAiUsageExecutionId,
4
+ assertSameCallExecution,
5
+ normalizeAiModelCallFilesRecord,
6
+ normalizeAiModelCallRecord,
7
+ } from "@sixb/core/internal/storage"
2
8
  import { isModelReasoning, normalizeModelProviderIds } from "@sixb/core/models"
3
9
  import type { ReadonlyJsonObject } from "@sixb/core/storage"
4
10
  import {
11
+ type AiModelCallFilesRecord,
5
12
  type AiModelCallUsageInput,
6
13
  type AiModelCallUsageRecord,
7
14
  type AiUsageExecutionSummary,
8
15
  type AiUsageStorage,
9
16
  AiUsageStorageError,
10
17
  aggregateAiModelCallUsage,
18
+ type GetAiModelCallFilesInput,
11
19
  type GetLatestAiModelCallForExecutionInput,
20
+ type RecordAiModelCallFilesInput,
12
21
  type RecordAiModelCallInput,
13
22
  type RecordAiModelCallResult,
14
23
  type SummarizeAiUsageExecutionInput,
@@ -51,6 +60,10 @@ export class PgAiUsageStorage implements AiUsageStorage {
51
60
  text_output_tokens,
52
61
  reasoning_output_tokens,
53
62
  audio_duration_ms,
63
+ image_output_tokens,
64
+ audio_output_tokens,
65
+ output_images,
66
+ input_characters,
54
67
  reporting_status,
55
68
  raw_usage,
56
69
  occurred_at,
@@ -81,6 +94,10 @@ export class PgAiUsageStorage implements AiUsageStorage {
81
94
  ${record.usage.textOutputTokens ?? null},
82
95
  ${record.usage.reasoningOutputTokens ?? null},
83
96
  ${record.usage.audioDurationMs ?? null},
97
+ ${record.usage.imageOutputTokens ?? null},
98
+ ${record.usage.audioOutputTokens ?? null},
99
+ ${record.usage.outputImages ?? null},
100
+ ${record.usage.inputCharacters ?? null},
84
101
  ${record.usage.reportingStatus},
85
102
  ${record.rawUsage === undefined ? null : JSON.stringify(record.rawUsage)}::text::jsonb,
86
103
  ${record.occurredAt},
@@ -235,6 +252,48 @@ export class PgAiUsageStorage implements AiUsageStorage {
235
252
  `
236
253
  }
237
254
 
255
+ async recordModelCallFiles(input: RecordAiModelCallFilesInput): Promise<AiModelCallFilesRecord> {
256
+ const record = normalizeAiModelCallFilesRecord(input)
257
+ return runPgTransaction(this.sql, async (tx) => {
258
+ const existing = await this.findCallFiles(tx, record.projectId, record.callId)
259
+ if (existing) return assertSameCallExecution(existing, record)
260
+ await this.assertExecutionExists(tx, record.projectId, record.executionId)
261
+ const inserted = await tx<CallFilesRow[]>`
262
+ INSERT INTO ai_model_call_files (project_id, call_id, execution_id, files, recorded_at)
263
+ VALUES (
264
+ ${record.projectId},
265
+ ${record.callId},
266
+ ${record.executionId},
267
+ ${JSON.stringify(record.files)}::text::jsonb,
268
+ ${record.recordedAt}
269
+ )
270
+ ON CONFLICT (project_id, call_id) DO NOTHING
271
+ RETURNING *
272
+ `
273
+ if (inserted[0]) return callFilesFromRow(inserted[0])
274
+ // Another writer recorded this call between the read and the insert.
275
+ const concurrent = await this.findCallFiles(tx, record.projectId, record.callId)
276
+ if (!concurrent) throw new Error("[SixbPg] AI model-call files disappeared after a conflict.")
277
+ return assertSameCallExecution(concurrent, record)
278
+ })
279
+ }
280
+
281
+ async getModelCallFiles(input: GetAiModelCallFilesInput): Promise<AiModelCallFilesRecord | null> {
282
+ assertNonBlankProjectId(input.projectId)
283
+ return this.findCallFiles(this.sql, input.projectId, input.callId)
284
+ }
285
+
286
+ private async findCallFiles(
287
+ sql: PgStoreClient,
288
+ projectId: string,
289
+ callId: string
290
+ ): Promise<AiModelCallFilesRecord | null> {
291
+ const [row] = await sql<CallFilesRow[]>`
292
+ SELECT * FROM ai_model_call_files WHERE project_id = ${projectId} AND call_id = ${callId}
293
+ `
294
+ return row ? callFilesFromRow(row) : null
295
+ }
296
+
238
297
  private async assertExecutionExists(
239
298
  sql: PgStoreClient,
240
299
  projectId: string,
@@ -253,6 +312,24 @@ export class PgAiUsageStorage implements AiUsageStorage {
253
312
  }
254
313
  }
255
314
 
315
+ interface CallFilesRow {
316
+ readonly project_id: string
317
+ readonly call_id: string
318
+ readonly execution_id: string
319
+ readonly files: FileRef[] | string
320
+ readonly recorded_at: Date | string
321
+ }
322
+
323
+ function callFilesFromRow(row: CallFilesRow): AiModelCallFilesRecord {
324
+ return {
325
+ projectId: row.project_id,
326
+ executionId: row.execution_id,
327
+ callId: row.call_id,
328
+ files: typeof row.files === "string" ? (JSON.parse(row.files) as FileRef[]) : row.files,
329
+ recordedAt: new Date(row.recorded_at),
330
+ }
331
+ }
332
+
256
333
  interface AiUsageRow {
257
334
  readonly project_id: string
258
335
  readonly id: string
@@ -275,6 +352,10 @@ interface AiUsageRow {
275
352
  readonly text_output_tokens: number | string | null
276
353
  readonly reasoning_output_tokens: number | string | null
277
354
  readonly audio_duration_ms: number | string | null
355
+ readonly image_output_tokens: number | string | null
356
+ readonly audio_output_tokens: number | string | null
357
+ readonly output_images: number | string | null
358
+ readonly input_characters: number | string | null
278
359
  readonly reporting_status: AiModelCallUsageRecord["usage"]["reportingStatus"]
279
360
  readonly raw_usage: ReadonlyJsonObject | string | null
280
361
  readonly occurred_at: Date | string
@@ -319,6 +400,14 @@ function usageFromRow(row: AiUsageRow): AiModelCallUsageInput {
319
400
  ? {}
320
401
  : { reasoningOutputTokens: Number(row.reasoning_output_tokens) }),
321
402
  ...(row.audio_duration_ms === null ? {} : { audioDurationMs: Number(row.audio_duration_ms) }),
403
+ ...(row.image_output_tokens === null
404
+ ? {}
405
+ : { imageOutputTokens: Number(row.image_output_tokens) }),
406
+ ...(row.audio_output_tokens === null
407
+ ? {}
408
+ : { audioOutputTokens: Number(row.audio_output_tokens) }),
409
+ ...(row.output_images === null ? {} : { outputImages: Number(row.output_images) }),
410
+ ...(row.input_characters === null ? {} : { inputCharacters: Number(row.input_characters) }),
322
411
  }
323
412
  }
324
413
 
@@ -0,0 +1,521 @@
1
+ import {
2
+ artifactStateBytes,
3
+ artifactStorageErrors,
4
+ checkArtifactStateWrite,
5
+ parseArtifactFrameGrantRow,
6
+ parseArtifactRow,
7
+ parseArtifactStateRow,
8
+ parseArtifactVersionRow,
9
+ } from "@sixb/core/internal/storage"
10
+ import type {
11
+ ArtifactFrameGrantRecord,
12
+ ArtifactRecord,
13
+ ArtifactStateEntry,
14
+ ArtifactStateKeyInput,
15
+ ArtifactStateScope,
16
+ ArtifactStorage,
17
+ ArtifactVersionInput,
18
+ ArtifactVersionRecord,
19
+ CopyArtifactInput,
20
+ CreateArtifactInput,
21
+ DeleteArtifactInput,
22
+ DeleteArtifactVersionsByOriginInput,
23
+ DeleteExpiredArtifactFrameGrantsInput,
24
+ FindArtifactFrameGrantInput,
25
+ GetArtifactInput,
26
+ GetArtifactVersionInput,
27
+ ListArtifactsInput,
28
+ ListArtifactsResult,
29
+ ListArtifactVersionsInput,
30
+ ListArtifactVersionsResult,
31
+ ListReferencingVersionsInput,
32
+ PublishArtifactVersionInput,
33
+ PublishedArtifactVersion,
34
+ SetArtifactStateInput,
35
+ } from "@sixb/core/storage"
36
+ import type { SQLClient, SqlParameter } from "./pg-client"
37
+ import { type PgStoreClient, runPgTransaction } from "./transactions"
38
+
39
+ interface PgRow {
40
+ readonly [column: string]: unknown
41
+ }
42
+
43
+ // Quoted aliases give the shared row parsers their camelCase keys; an unquoted alias would fold to
44
+ // lower case.
45
+ const ARTIFACT_COLUMNS = `
46
+ project_id AS "projectId", id, kind, title,
47
+ owner_principal_type AS "ownerPrincipalType", owner_principal_id AS "ownerPrincipalId",
48
+ latest_version AS "latestVersion", created_at AS "createdAt", updated_at AS "updatedAt"
49
+ `
50
+
51
+ const VERSION_COLUMNS = `
52
+ project_id AS "projectId", artifact_id AS "artifactId", version, title, entry, files, outline,
53
+ refs, author_principal_type AS "authorPrincipalType", author_principal_id AS "authorPrincipalId",
54
+ origin_thread_id AS "originThreadId", origin_run_id AS "originRunId", created_at AS "createdAt"
55
+ `
56
+
57
+ const FRAME_GRANT_COLUMNS = `
58
+ project_id AS "projectId", token_hash AS "tokenHash", artifact_id AS "artifactId", version,
59
+ targets, created_at AS "createdAt", expires_at AS "expiresAt"
60
+ `
61
+
62
+ const STATE_COLUMNS = `key, value, size_bytes AS "sizeBytes", updated_at AS "updatedAt"`
63
+
64
+ export class PgArtifactStorage implements ArtifactStorage {
65
+ constructor(private readonly sql: PgStoreClient) {}
66
+
67
+ async create(input: CreateArtifactInput): Promise<PublishedArtifactVersion> {
68
+ return runPgTransaction(this.sql, async (tx) => {
69
+ const [artifact] = await tx<PgRow[]>`
70
+ INSERT INTO artifacts (
71
+ project_id, id, kind, title, owner_principal_type, owner_principal_id,
72
+ latest_version, next_version, created_at, updated_at
73
+ ) VALUES (
74
+ ${input.projectId}, ${input.id}, ${input.kind}, ${input.version.title},
75
+ ${input.owner.type}, ${input.owner.id}, 1, 2,
76
+ ${input.version.createdAt}, ${input.version.createdAt}
77
+ )
78
+ ON CONFLICT (project_id, id) DO NOTHING
79
+ RETURNING ${tx.unsafe(ARTIFACT_COLUMNS)}
80
+ `
81
+ if (!artifact) throw artifactStorageErrors.exists(input.id)
82
+ return {
83
+ artifact: parseArtifactRow(artifact),
84
+ version: await insertVersion(tx, input.projectId, input.id, 1, input.version),
85
+ }
86
+ })
87
+ }
88
+
89
+ async publish(input: PublishArtifactVersionInput): Promise<PublishedArtifactVersion> {
90
+ return runPgTransaction(this.sql, async (tx) => {
91
+ // The row lock serializes concurrent publishes of one artifact.
92
+ const [current] = await tx<PgRow[]>`
93
+ SELECT kind, next_version, latest_version FROM artifacts
94
+ WHERE project_id = ${input.projectId} AND id = ${input.artifactId}
95
+ FOR UPDATE
96
+ `
97
+ if (!current) throw artifactStorageErrors.notFound(input.artifactId)
98
+ const kind = current.kind as ArtifactRecord["kind"]
99
+ if (kind !== input.kind) {
100
+ throw artifactStorageErrors.kindMismatch(input.artifactId, kind, input.kind)
101
+ }
102
+ // Under the row lock: a publish from the same base after this one finds it moved.
103
+ const latestVersion = Number(current.latest_version)
104
+ if (input.baseVersion !== undefined && latestVersion !== input.baseVersion) {
105
+ const [row] = await tx<PgRow[]>`
106
+ SELECT ${tx.unsafe(VERSION_COLUMNS)} FROM artifact_versions
107
+ WHERE project_id = ${input.projectId} AND artifact_id = ${input.artifactId}
108
+ AND version = ${latestVersion}
109
+ `
110
+ const latest = parseArtifactVersionRow(row as PgRow)
111
+ throw artifactStorageErrors.moved(input.artifactId, input.baseVersion, {
112
+ version: latest.version,
113
+ author: latest.author,
114
+ })
115
+ }
116
+ const version = await insertVersion(
117
+ tx,
118
+ input.projectId,
119
+ input.artifactId,
120
+ Number(current.next_version),
121
+ input.version
122
+ )
123
+ const [artifact] = await tx<PgRow[]>`
124
+ UPDATE artifacts
125
+ SET title = ${input.version.title}, latest_version = ${version.version},
126
+ next_version = ${version.version + 1}, updated_at = ${input.version.createdAt}
127
+ WHERE project_id = ${input.projectId} AND id = ${input.artifactId}
128
+ RETURNING ${tx.unsafe(ARTIFACT_COLUMNS)}
129
+ `
130
+ return { artifact: parseArtifactRow(artifact), version }
131
+ })
132
+ }
133
+
134
+ async get(input: GetArtifactInput): Promise<ArtifactRecord | null> {
135
+ const [row] = await this.sql<PgRow[]>`
136
+ SELECT ${this.sql.unsafe(ARTIFACT_COLUMNS)} FROM artifacts
137
+ WHERE project_id = ${input.projectId} AND id = ${input.id}
138
+ `
139
+ return row ? parseArtifactRow(row) : null
140
+ }
141
+
142
+ async list(input: ListArtifactsInput): Promise<ListArtifactsResult> {
143
+ const where = ["a.project_id = $1"]
144
+ const params: SqlParameter[] = [input.projectId]
145
+ if (input.owner) {
146
+ where.push(`a.owner_principal_type = $${params.length + 1}`)
147
+ params.push(input.owner.type)
148
+ where.push(`a.owner_principal_id = $${params.length + 1}`)
149
+ params.push(input.owner.id)
150
+ }
151
+ if (input.threadId !== undefined) {
152
+ where.push(
153
+ `EXISTS (
154
+ SELECT 1 FROM artifact_threads t
155
+ WHERE t.project_id = a.project_id AND t.artifact_id = a.id
156
+ AND t.thread_id = $${params.length + 1}
157
+ )`
158
+ )
159
+ params.push(input.threadId)
160
+ }
161
+ if (input.kind !== undefined) {
162
+ where.push(`a.kind = $${params.length + 1}`)
163
+ params.push(input.kind)
164
+ }
165
+ const clause = where.join(" AND ")
166
+ const [counted] = await this.sql.unsafe<{ total: number }[]>(
167
+ `SELECT COUNT(*)::int AS total FROM artifacts a WHERE ${clause}`,
168
+ params
169
+ )
170
+ const offset = input.offset ?? 0
171
+ const rows = await this.sql.unsafe<PgRow[]>(
172
+ `SELECT ${ARTIFACT_COLUMNS} FROM artifacts a WHERE ${clause}
173
+ ORDER BY a.updated_at DESC, a.id ASC
174
+ LIMIT $${params.length + 1} OFFSET $${params.length + 2}`,
175
+ [...params, input.limit ?? null, offset]
176
+ )
177
+ const total = counted?.total ?? 0
178
+ return { artifacts: rows.map(parseArtifactRow), hasMore: offset + rows.length < total, total }
179
+ }
180
+
181
+ async getVersion(input: GetArtifactVersionInput): Promise<ArtifactVersionRecord | null> {
182
+ const [row] = await this.sql<PgRow[]>`
183
+ SELECT ${this.sql.unsafe(VERSION_COLUMNS)} FROM artifact_versions
184
+ WHERE project_id = ${input.projectId} AND artifact_id = ${input.artifactId}
185
+ AND version = ${input.version}
186
+ `
187
+ return row ? parseArtifactVersionRow(row) : null
188
+ }
189
+
190
+ async listThreadIds(input: GetArtifactInput): Promise<string[]> {
191
+ const rows = await this.sql<{ thread_id: string }[]>`
192
+ SELECT thread_id FROM artifact_threads
193
+ WHERE project_id = ${input.projectId} AND artifact_id = ${input.id}
194
+ ORDER BY thread_id COLLATE "C"
195
+ `
196
+ return rows.map((row) => row.thread_id)
197
+ }
198
+
199
+ async copy(input: CopyArtifactInput): Promise<ArtifactRecord> {
200
+ return runPgTransaction(this.sql, async (tx) => {
201
+ // The share lock keeps the source from being deleted or published to while it is copied, so
202
+ // the copy holds every version it has.
203
+ const [source] = await tx<PgRow[]>`
204
+ SELECT id FROM artifacts WHERE project_id = ${input.projectId} AND id = ${input.sourceId}
205
+ FOR SHARE
206
+ `
207
+ if (!source) throw artifactStorageErrors.notFound(input.sourceId)
208
+
209
+ const [artifact] = await tx<PgRow[]>`
210
+ INSERT INTO artifacts (
211
+ project_id, id, kind, title, owner_principal_type, owner_principal_id,
212
+ latest_version, next_version, created_at, updated_at
213
+ )
214
+ SELECT project_id, ${input.id}, kind, title, ${input.owner.type}, ${input.owner.id},
215
+ latest_version, next_version, created_at, updated_at
216
+ FROM artifacts WHERE project_id = ${input.projectId} AND id = ${input.sourceId}
217
+ ON CONFLICT (project_id, id) DO NOTHING
218
+ RETURNING ${tx.unsafe(ARTIFACT_COLUMNS)}
219
+ `
220
+ if (!artifact) throw artifactStorageErrors.exists(input.id)
221
+
222
+ await tx`
223
+ INSERT INTO artifact_versions (
224
+ project_id, artifact_id, version, title, entry, files, outline, refs,
225
+ author_principal_type, author_principal_id, created_at
226
+ )
227
+ SELECT project_id, ${input.id}, version, title, entry, files, outline, refs,
228
+ author_principal_type, author_principal_id, created_at
229
+ FROM artifact_versions
230
+ WHERE project_id = ${input.projectId} AND artifact_id = ${input.sourceId}
231
+ `
232
+ await tx`
233
+ INSERT INTO artifact_threads (project_id, artifact_id, thread_id, shared_at, copied)
234
+ VALUES (
235
+ ${input.projectId}, ${input.id}, ${input.threadId}, ${input.copiedAt ?? new Date()}, TRUE
236
+ )
237
+ `
238
+ return parseArtifactRow(artifact)
239
+ })
240
+ }
241
+
242
+ async listVersions(input: ListArtifactVersionsInput): Promise<ListArtifactVersionsResult> {
243
+ const [counted] = await this.sql<{ total: number }[]>`
244
+ SELECT COUNT(*)::int AS total FROM artifact_versions
245
+ WHERE project_id = ${input.projectId} AND artifact_id = ${input.artifactId}
246
+ `
247
+ const offset = input.offset ?? 0
248
+ const rows = await this.sql<PgRow[]>`
249
+ SELECT ${this.sql.unsafe(VERSION_COLUMNS)} FROM artifact_versions
250
+ WHERE project_id = ${input.projectId} AND artifact_id = ${input.artifactId}
251
+ ORDER BY version DESC
252
+ LIMIT ${input.limit ?? null} OFFSET ${offset}
253
+ `
254
+ const total = counted?.total ?? 0
255
+ return {
256
+ versions: rows.map(parseArtifactVersionRow),
257
+ hasMore: offset + rows.length < total,
258
+ total,
259
+ }
260
+ }
261
+
262
+ async listReferencingVersions(
263
+ input: ListReferencingVersionsInput
264
+ ): Promise<ArtifactVersionRecord[]> {
265
+ // An address names the artifact when the id is followed by nothing, a version, a file, or a
266
+ // part.
267
+ const prefix = `artifact:${input.artifactId}`
268
+ const rows = await this.sql<PgRow[]>`
269
+ SELECT ${this.sql.unsafe(VERSION_COLUMNS)} FROM artifact_versions v
270
+ WHERE project_id = ${input.projectId} AND (artifact_id, version) IN (
271
+ SELECT id, latest_version FROM artifacts WHERE project_id = ${input.projectId}
272
+ ) AND EXISTS (
273
+ SELECT 1 FROM jsonb_array_elements(v.refs) r
274
+ WHERE left(r ->> 'address', ${prefix.length}::int) = ${prefix}
275
+ AND substr(r ->> 'address', ${prefix.length + 1}::int, 1) IN ('', '@', '/', '#')
276
+ )
277
+ ORDER BY artifact_id
278
+ `
279
+ return rows.map(parseArtifactVersionRow)
280
+ }
281
+
282
+ async createFrameGrant(record: ArtifactFrameGrantRecord): Promise<void> {
283
+ const rows = await this.sql`
284
+ INSERT INTO artifact_frame_grants
285
+ (project_id, token_hash, artifact_id, version, targets, created_at, expires_at)
286
+ SELECT ${record.projectId}, ${record.tokenHash}, ${record.artifactId}, ${record.version},
287
+ ${JSON.stringify(record.targets)}::text::jsonb, ${record.createdAt},
288
+ ${record.expiresAt}
289
+ WHERE EXISTS (
290
+ SELECT 1 FROM artifacts WHERE project_id = ${record.projectId} AND id = ${record.artifactId}
291
+ )
292
+ RETURNING token_hash
293
+ `
294
+ if (rows.length === 0) throw artifactStorageErrors.notFound(record.artifactId)
295
+ }
296
+
297
+ async findFrameGrant(
298
+ input: FindArtifactFrameGrantInput
299
+ ): Promise<ArtifactFrameGrantRecord | null> {
300
+ const [row] = await this.sql<PgRow[]>`
301
+ SELECT ${this.sql.unsafe(FRAME_GRANT_COLUMNS)} FROM artifact_frame_grants
302
+ WHERE project_id = ${input.projectId} AND token_hash = ${input.tokenHash}
303
+ `
304
+ return row ? parseArtifactFrameGrantRow(row) : null
305
+ }
306
+
307
+ async deleteExpiredFrameGrants(input: DeleteExpiredArtifactFrameGrantsInput): Promise<number> {
308
+ const rows = await this.sql`
309
+ DELETE FROM artifact_frame_grants
310
+ WHERE (project_id, token_hash) IN (
311
+ SELECT project_id, token_hash FROM artifact_frame_grants
312
+ WHERE project_id = ${input.projectId} AND expires_at < ${input.expiredBefore}
313
+ ORDER BY expires_at LIMIT ${input.limit}
314
+ )
315
+ RETURNING token_hash
316
+ `
317
+ return rows.length
318
+ }
319
+
320
+ async getState(input: ArtifactStateKeyInput): Promise<ArtifactStateEntry | null> {
321
+ const [row] = await this.sql<PgRow[]>`
322
+ SELECT ${this.sql.unsafe(STATE_COLUMNS)} FROM artifact_state
323
+ WHERE project_id = ${input.projectId} AND artifact_id = ${input.artifactId}
324
+ AND principal_type = ${input.principal.type} AND principal_id = ${input.principal.id}
325
+ AND key = ${input.key}
326
+ `
327
+ return row ? parseArtifactStateRow(row) : null
328
+ }
329
+
330
+ async setState(input: SetArtifactStateInput): Promise<void> {
331
+ await runPgTransaction(this.sql, async (tx) => {
332
+ // The key share lock keeps the artifact from being deleted under the write, without
333
+ // waiting on a publish.
334
+ const [artifact] = await tx<PgRow[]>`
335
+ SELECT 1 FROM artifacts WHERE project_id = ${input.projectId} AND id = ${input.artifactId}
336
+ FOR KEY SHARE
337
+ `
338
+ if (!artifact) throw artifactStorageErrors.notFound(input.artifactId)
339
+ // One reader's writes to one artifact take turns, so two at once cannot both fit under the
340
+ // limits; other readers' writes do not wait.
341
+ await tx`
342
+ SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify([
343
+ "artifact_state",
344
+ input.projectId,
345
+ input.artifactId,
346
+ input.principal.type,
347
+ input.principal.id,
348
+ ])}, 0))
349
+ `
350
+ const [others] = await tx<PgRow[]>`
351
+ SELECT count(*) AS keys, coalesce(sum(size_bytes), 0) AS bytes FROM artifact_state
352
+ WHERE project_id = ${input.projectId} AND artifact_id = ${input.artifactId}
353
+ AND principal_type = ${input.principal.type} AND principal_id = ${input.principal.id}
354
+ AND key <> ${input.key}
355
+ `
356
+ const sizeBytes = artifactStateBytes(input.value)
357
+ checkArtifactStateWrite({
358
+ key: input.key,
359
+ valueBytes: sizeBytes,
360
+ others: { keys: Number(others?.keys ?? 0), bytes: Number(others?.bytes ?? 0) },
361
+ })
362
+ await tx`
363
+ INSERT INTO artifact_state (
364
+ project_id, artifact_id, principal_type, principal_id, key, value, size_bytes,
365
+ updated_at
366
+ ) VALUES (
367
+ ${input.projectId}, ${input.artifactId}, ${input.principal.type},
368
+ ${input.principal.id}, ${input.key}, ${input.value}, ${sizeBytes}, ${input.updatedAt}
369
+ )
370
+ ON CONFLICT (project_id, artifact_id, principal_type, principal_id, key) DO UPDATE SET
371
+ value = excluded.value, size_bytes = excluded.size_bytes,
372
+ updated_at = excluded.updated_at
373
+ `
374
+ })
375
+ }
376
+
377
+ async deleteState(input: ArtifactStateKeyInput): Promise<boolean> {
378
+ const rows = await this.sql`
379
+ DELETE FROM artifact_state
380
+ WHERE project_id = ${input.projectId} AND artifact_id = ${input.artifactId}
381
+ AND principal_type = ${input.principal.type} AND principal_id = ${input.principal.id}
382
+ AND key = ${input.key}
383
+ RETURNING key
384
+ `
385
+ return rows.length > 0
386
+ }
387
+
388
+ async listState(input: ArtifactStateScope): Promise<ArtifactStateEntry[]> {
389
+ // COLLATE "C": code-unit order, as SQLite and the in-memory store compare keys.
390
+ const rows = await this.sql<PgRow[]>`
391
+ SELECT ${this.sql.unsafe(STATE_COLUMNS)} FROM artifact_state
392
+ WHERE project_id = ${input.projectId} AND artifact_id = ${input.artifactId}
393
+ AND principal_type = ${input.principal.type} AND principal_id = ${input.principal.id}
394
+ ORDER BY key COLLATE "C"
395
+ `
396
+ return rows.map(parseArtifactStateRow)
397
+ }
398
+
399
+ async clearState(input: ArtifactStateScope): Promise<number> {
400
+ const rows = await this.sql`
401
+ DELETE FROM artifact_state
402
+ WHERE project_id = ${input.projectId} AND artifact_id = ${input.artifactId}
403
+ AND principal_type = ${input.principal.type} AND principal_id = ${input.principal.id}
404
+ RETURNING key
405
+ `
406
+ return rows.length
407
+ }
408
+
409
+ async delete(input: DeleteArtifactInput): Promise<boolean> {
410
+ // Versions, frame grants, and state go with the artifact through ON DELETE CASCADE.
411
+ const rows = await this.sql`
412
+ DELETE FROM artifacts WHERE project_id = ${input.projectId} AND id = ${input.id}
413
+ RETURNING id
414
+ `
415
+ return rows.length > 0
416
+ }
417
+
418
+ async deleteVersionsByOrigin(input: DeleteArtifactVersionsByOriginInput): Promise<number> {
419
+ return runPgTransaction(this.sql, async (tx) => {
420
+ // Lock the run's artifacts first, in id order so two of these never deadlock. `publish`
421
+ // holds the same lock while it adds a version, and under READ COMMITTED each statement
422
+ // below then reads a snapshot taken after any publish that held it committed. Without the
423
+ // lock they would wait on a publish's row lock and then apply what they read before it: the
424
+ // UPDATE would point `latest_version` back past the new version, and the DELETE would drop
425
+ // an artifact whose versions were all the run's, cascading the new version with it.
426
+ const locked = await tx<{ id: string }[]>`
427
+ SELECT id FROM artifacts
428
+ WHERE project_id = ${input.projectId} AND id IN (
429
+ SELECT artifact_id FROM artifact_versions
430
+ WHERE project_id = ${input.projectId} AND origin_run_id = ${input.runId}
431
+ )
432
+ ORDER BY id
433
+ FOR UPDATE
434
+ `
435
+ const affected = locked.map((row) => row.id)
436
+ if (affected.length === 0) return 0
437
+ const deleted = await tx<{ origin_thread_id: string }[]>`
438
+ DELETE FROM artifact_versions
439
+ WHERE project_id = ${input.projectId} AND origin_run_id = ${input.runId}
440
+ AND artifact_id IN ${tx(affected)}
441
+ RETURNING origin_thread_id
442
+ `
443
+ // A run publishes from one thread: its artifacts stay shared there only while a version
444
+ // from it remains, or when they were copied for it.
445
+ const threadId = deleted[0]?.origin_thread_id
446
+ await tx`
447
+ DELETE FROM artifact_threads t
448
+ WHERE t.project_id = ${input.projectId} AND t.artifact_id IN ${tx(affected)}
449
+ AND t.thread_id = ${threadId ?? null} AND NOT t.copied
450
+ AND NOT EXISTS (
451
+ SELECT 1 FROM artifact_versions v
452
+ WHERE v.project_id = t.project_id AND v.artifact_id = t.artifact_id
453
+ AND v.origin_thread_id = t.thread_id
454
+ )
455
+ `
456
+ // An artifact left without versions goes; the rest point at their newest remaining one.
457
+ await tx`
458
+ DELETE FROM artifacts a
459
+ WHERE a.project_id = ${input.projectId} AND a.id IN ${tx(affected)}
460
+ AND NOT EXISTS (
461
+ SELECT 1 FROM artifact_versions v
462
+ WHERE v.project_id = a.project_id AND v.artifact_id = a.id
463
+ )
464
+ `
465
+ await tx`
466
+ UPDATE artifacts a
467
+ SET latest_version = latest.version, title = latest.title, updated_at = latest.created_at
468
+ FROM (
469
+ SELECT DISTINCT ON (artifact_id) artifact_id, version, title, created_at
470
+ FROM artifact_versions
471
+ WHERE project_id = ${input.projectId} AND artifact_id IN ${tx(affected)}
472
+ ORDER BY artifact_id, version DESC
473
+ ) latest
474
+ WHERE a.project_id = ${input.projectId} AND a.id = latest.artifact_id
475
+ `
476
+ return deleted.length
477
+ })
478
+ }
479
+ }
480
+
481
+ async function insertVersion(
482
+ sql: SQLClient,
483
+ projectId: string,
484
+ artifactId: string,
485
+ version: number,
486
+ input: ArtifactVersionInput
487
+ ): Promise<ArtifactVersionRecord> {
488
+ const [row] = await sql<PgRow[]>`
489
+ INSERT INTO artifact_versions (
490
+ project_id, artifact_id, version, title, entry, files, outline, refs,
491
+ author_principal_type, author_principal_id, origin_thread_id, origin_run_id, created_at
492
+ ) VALUES (
493
+ ${projectId}, ${artifactId}, ${version}, ${input.title}, ${input.entry},
494
+ ${JSON.stringify(input.files)}::text::jsonb,
495
+ ${JSON.stringify(input.outline)}::text::jsonb,
496
+ ${JSON.stringify(input.references)}::text::jsonb,
497
+ ${input.author.type}, ${input.author.id},
498
+ ${input.origin?.threadId ?? null}, ${input.origin?.runId ?? null}, ${input.createdAt}
499
+ )
500
+ RETURNING ${sql.unsafe(VERSION_COLUMNS)}
501
+ `
502
+ if (input.origin) {
503
+ await linkThread(sql, projectId, artifactId, input.origin.threadId, input.createdAt)
504
+ }
505
+ return parseArtifactVersionRow(row)
506
+ }
507
+
508
+ /** Shares an artifact in a thread, keeping when it was first shared there. */
509
+ async function linkThread(
510
+ sql: SQLClient,
511
+ projectId: string,
512
+ artifactId: string,
513
+ threadId: string,
514
+ sharedAt: Date
515
+ ): Promise<void> {
516
+ await sql`
517
+ INSERT INTO artifact_threads (project_id, artifact_id, thread_id, shared_at)
518
+ VALUES (${projectId}, ${artifactId}, ${threadId}, ${sharedAt})
519
+ ON CONFLICT (project_id, artifact_id, thread_id) DO NOTHING
520
+ `
521
+ }