@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.
- package/README.md +1 -0
- package/dist/agents/agent-storage.d.ts +8 -2
- package/dist/agents/agent-storage.d.ts.map +1 -1
- package/dist/agents/approvals.d.ts +48 -0
- package/dist/agents/approvals.d.ts.map +1 -0
- package/dist/agents/checkpoints.d.ts +1 -0
- package/dist/agents/checkpoints.d.ts.map +1 -1
- package/dist/agents/index.d.ts +1 -0
- package/dist/agents/index.d.ts.map +1 -1
- package/dist/agents/memory.d.ts +19 -0
- package/dist/agents/memory.d.ts.map +1 -0
- package/dist/agents/messages.d.ts.map +1 -1
- package/dist/agents/rows.d.ts +15 -1
- package/dist/agents/rows.d.ts.map +1 -1
- package/dist/agents/runs.d.ts +3 -1
- package/dist/agents/runs.d.ts.map +1 -1
- package/dist/agents/settings.d.ts +29 -0
- package/dist/agents/settings.d.ts.map +1 -0
- package/dist/agents/threads.d.ts +10 -1
- package/dist/agents/threads.d.ts.map +1 -1
- package/dist/ai-cost-groups.d.ts.map +1 -1
- package/dist/auth-storage/auth-storage.d.ts +3 -2
- package/dist/auth-storage/auth-storage.d.ts.map +1 -1
- package/dist/auth-storage/sessions.d.ts +2 -1
- package/dist/auth-storage/sessions.d.ts.map +1 -1
- package/dist/auth-storage/users.d.ts +8 -4
- package/dist/auth-storage/users.d.ts.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6001 -2923
- package/dist/index.js.map +45 -37
- package/dist/migrations.d.ts.map +1 -1
- package/dist/objects/conditional-reader.d.ts +17 -0
- package/dist/objects/conditional-reader.d.ts.map +1 -0
- package/dist/objects/query-compiler.d.ts +27 -1
- package/dist/objects/query-compiler.d.ts.map +1 -1
- package/dist/objects/query-indexes.d.ts.map +1 -1
- package/dist/objects/read-scope.d.ts +10 -1
- package/dist/objects/read-scope.d.ts.map +1 -1
- package/dist/objects/reader.d.ts +2 -0
- package/dist/objects/reader.d.ts.map +1 -1
- package/dist/objects/scoped-reader.d.ts +12 -0
- package/dist/objects/scoped-reader.d.ts.map +1 -0
- package/dist/objects/selected-reader.d.ts +1 -0
- package/dist/objects/selected-reader.d.ts.map +1 -1
- package/dist/objects/storage.d.ts +7 -1
- package/dist/objects/storage.d.ts.map +1 -1
- package/dist/ontology-storage/index.d.ts +2 -0
- package/dist/ontology-storage/index.d.ts.map +1 -1
- package/dist/ontology-storage/materialization-session.d.ts +6 -0
- package/dist/ontology-storage/materialization-session.d.ts.map +1 -1
- package/dist/ontology-storage/materialization-writer.d.ts +15 -0
- package/dist/ontology-storage/materialization-writer.d.ts.map +1 -1
- package/dist/ontology-storage/materializations.d.ts.map +1 -1
- package/dist/ontology-storage/replacement-plans.d.ts.map +1 -1
- package/dist/ontology-storage/sources.d.ts +2 -1
- package/dist/ontology-storage/sources.d.ts.map +1 -1
- package/dist/ontology-storage/unique-values.d.ts +60 -0
- package/dist/ontology-storage/unique-values.d.ts.map +1 -0
- package/dist/pg-action-run-storage.d.ts +4 -0
- package/dist/pg-action-run-storage.d.ts.map +1 -1
- package/dist/pg-activity-storage.d.ts +22 -0
- package/dist/pg-activity-storage.d.ts.map +1 -0
- package/dist/pg-ai-cost-storage.d.ts.map +1 -1
- package/dist/pg-ai-usage-storage.d.ts +8 -1
- package/dist/pg-ai-usage-storage.d.ts.map +1 -1
- package/dist/pg-artifact-storage.d.ts +26 -0
- package/dist/pg-artifact-storage.d.ts.map +1 -0
- package/dist/pg-execution-storage.d.ts +3 -1
- package/dist/pg-execution-storage.d.ts.map +1 -1
- package/dist/pg-workflow-run-storage.d.ts +4 -0
- package/dist/pg-workflow-run-storage.d.ts.map +1 -1
- package/dist/share-grant-storage.d.ts +2 -1
- package/dist/share-grant-storage.d.ts.map +1 -1
- package/dist/storage-errors.d.ts +2 -0
- package/dist/storage-errors.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/agents/agent-storage.ts +11 -2
- package/src/agents/approvals.ts +400 -0
- package/src/agents/checkpoints.ts +8 -4
- package/src/agents/index.ts +1 -0
- package/src/agents/memory.ts +175 -0
- package/src/agents/messages.ts +14 -7
- package/src/agents/rows.ts +46 -1
- package/src/agents/runs.ts +154 -22
- package/src/agents/settings.ts +105 -0
- package/src/agents/threads.ts +379 -3
- package/src/ai-cost-groups.ts +11 -1
- package/src/auth-storage/auth-storage.ts +45 -13
- package/src/auth-storage/sessions.ts +20 -0
- package/src/auth-storage/users.ts +105 -43
- package/src/index.ts +10 -0
- package/src/migrations/063-artifacts.sql +67 -0
- package/src/migrations/064-drop-output-diagnostics.sql +14 -0
- package/src/migrations/065-plan-work-payload-statistics.sql +5 -0
- package/src/migrations/066-activity.sql +61 -0
- package/src/migrations/067-activity-timeline.sql +15 -0
- package/src/migrations/068-activity-interactions.sql +20 -0
- package/src/migrations/069-artifact-kinds.sql +4 -0
- package/src/migrations/070-artifact-state.sql +16 -0
- package/src/migrations/071-agent-thread-participants.sql +16 -0
- package/src/migrations/072-forked-context-checkpoints.sql +23 -0
- package/src/migrations/073-unique-values.sql +25 -0
- package/src/migrations/074-generated-media-usage.sql +21 -0
- package/src/migrations/075-model-call-files.sql +11 -0
- package/src/migrations/076-agent-memory.sql +33 -0
- package/src/migrations/077-artifact-threads.sql +21 -0
- package/src/migrations/078-agent-approvals.sql +58 -0
- package/src/migrations/079-agent-approval-resume.sql +20 -0
- package/src/migrations/080-subagent-threads.sql +145 -0
- package/src/migrations/081-subagent-questions.sql +6 -0
- package/src/migrations.ts +52 -0
- package/src/objects/conditional-reader.ts +44 -0
- package/src/objects/prepare-queries.ts +1 -1
- package/src/objects/query-compiler.ts +96 -33
- package/src/objects/query-indexes.ts +29 -10
- package/src/objects/read-scope.ts +140 -25
- package/src/objects/reader.ts +50 -6
- package/src/objects/scoped-reader.ts +101 -0
- package/src/objects/selected-reader.ts +16 -76
- package/src/objects/storage.ts +24 -5
- package/src/objects/text-count-index.ts +1 -0
- package/src/ontology-storage/index.ts +3 -0
- package/src/ontology-storage/materialization-session.ts +8 -0
- package/src/ontology-storage/materialization-writer.ts +76 -1
- package/src/ontology-storage/materializations.ts +20 -1
- package/src/ontology-storage/replacement-plans.ts +25 -7
- package/src/ontology-storage/sources.ts +37 -0
- package/src/ontology-storage/unique-values.ts +274 -0
- package/src/pg-action-run-storage.ts +23 -0
- package/src/pg-activity-storage.ts +486 -0
- package/src/pg-ai-cost-storage.ts +62 -12
- package/src/pg-ai-usage-storage.ts +90 -1
- package/src/pg-artifact-storage.ts +521 -0
- package/src/pg-execution-storage.ts +83 -0
- package/src/pg-workflow-run-storage.ts +23 -0
- package/src/share-grant-storage.ts +21 -0
- package/src/storage-errors.ts +7 -0
- package/src/transactions.ts +1 -1
|
@@ -1,14 +1,23 @@
|
|
|
1
|
-
import {
|
|
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
|
+
}
|