@sixb/sqlite 0.1.21 → 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 (100) hide show
  1. package/dist/agents/agent-storage.d.ts +8 -2
  2. package/dist/agents/agent-storage.d.ts.map +1 -1
  3. package/dist/agents/approvals.d.ts +51 -0
  4. package/dist/agents/approvals.d.ts.map +1 -0
  5. package/dist/agents/checkpoints.d.ts +1 -0
  6. package/dist/agents/checkpoints.d.ts.map +1 -1
  7. package/dist/agents/index.d.ts +1 -0
  8. package/dist/agents/index.d.ts.map +1 -1
  9. package/dist/agents/memory.d.ts +21 -0
  10. package/dist/agents/memory.d.ts.map +1 -0
  11. package/dist/agents/messages.d.ts.map +1 -1
  12. package/dist/agents/rows.d.ts +15 -1
  13. package/dist/agents/rows.d.ts.map +1 -1
  14. package/dist/agents/runs.d.ts +14 -1
  15. package/dist/agents/runs.d.ts.map +1 -1
  16. package/dist/agents/settings.d.ts +29 -0
  17. package/dist/agents/settings.d.ts.map +1 -0
  18. package/dist/agents/threads.d.ts +12 -1
  19. package/dist/agents/threads.d.ts.map +1 -1
  20. package/dist/ai-cost-groups.d.ts.map +1 -1
  21. package/dist/ai-cost-storage.d.ts.map +1 -1
  22. package/dist/ai-usage-storage.d.ts +4 -1
  23. package/dist/ai-usage-storage.d.ts.map +1 -1
  24. package/dist/artifact-storage.d.ts +10 -1
  25. package/dist/artifact-storage.d.ts.map +1 -1
  26. package/dist/auth-storage/auth-storage.d.ts +3 -2
  27. package/dist/auth-storage/auth-storage.d.ts.map +1 -1
  28. package/dist/auth-storage/users.d.ts +7 -3
  29. package/dist/auth-storage/users.d.ts.map +1 -1
  30. package/dist/execution-storage.d.ts +2 -1
  31. package/dist/execution-storage.d.ts.map +1 -1
  32. package/dist/index.js +3348 -1515
  33. package/dist/index.js.map +32 -26
  34. package/dist/migrations.d.ts +0 -1
  35. package/dist/migrations.d.ts.map +1 -1
  36. package/dist/objects/conditional-reader.d.ts +8 -0
  37. package/dist/objects/conditional-reader.d.ts.map +1 -0
  38. package/dist/objects/query-compiler.d.ts +26 -1
  39. package/dist/objects/query-compiler.d.ts.map +1 -1
  40. package/dist/objects/read-scope.d.ts +10 -1
  41. package/dist/objects/read-scope.d.ts.map +1 -1
  42. package/dist/objects/reader.d.ts +2 -0
  43. package/dist/objects/reader.d.ts.map +1 -1
  44. package/dist/objects/scoped-reader.d.ts +15 -0
  45. package/dist/objects/scoped-reader.d.ts.map +1 -0
  46. package/dist/objects/selected-reader.d.ts.map +1 -1
  47. package/dist/objects/storage.d.ts +6 -1
  48. package/dist/objects/storage.d.ts.map +1 -1
  49. package/dist/ontology-storage/index.d.ts +2 -0
  50. package/dist/ontology-storage/index.d.ts.map +1 -1
  51. package/dist/ontology-storage/materialization-writer.d.ts +2 -0
  52. package/dist/ontology-storage/materialization-writer.d.ts.map +1 -1
  53. package/dist/ontology-storage/materializations.d.ts.map +1 -1
  54. package/dist/ontology-storage/unique-values.d.ts +42 -0
  55. package/dist/ontology-storage/unique-values.d.ts.map +1 -0
  56. package/package.json +3 -3
  57. package/src/agents/agent-storage.ts +11 -2
  58. package/src/agents/approvals.ts +456 -0
  59. package/src/agents/checkpoints.ts +9 -4
  60. package/src/agents/index.ts +1 -0
  61. package/src/agents/memory.ts +195 -0
  62. package/src/agents/messages.ts +21 -18
  63. package/src/agents/rows.ts +46 -1
  64. package/src/agents/runs.ts +202 -21
  65. package/src/agents/settings.ts +112 -0
  66. package/src/agents/threads.ts +385 -1
  67. package/src/ai-cost-groups.ts +11 -1
  68. package/src/ai-cost-storage.ts +54 -12
  69. package/src/ai-usage-storage.ts +79 -2
  70. package/src/artifact-storage.ts +206 -9
  71. package/src/auth-storage/auth-storage.ts +58 -26
  72. package/src/auth-storage/users.ts +106 -60
  73. package/src/execution-storage.ts +39 -0
  74. package/src/migrations/067-artifact-kinds.sql +32 -0
  75. package/src/migrations/068-artifact-state.sql +15 -0
  76. package/src/migrations/069-agent-thread-participants.sql +16 -0
  77. package/src/migrations/070-forked-context-checkpoints.sql +45 -0
  78. package/src/migrations/071-unique-values.sql +22 -0
  79. package/src/migrations/072-generated-media-usage.sql +32 -0
  80. package/src/migrations/073-model-call-files.sql +11 -0
  81. package/src/migrations/074-agent-memory.sql +32 -0
  82. package/src/migrations/075-artifact-threads.sql +21 -0
  83. package/src/migrations/076-agent-approvals.sql +59 -0
  84. package/src/migrations/077-agent-approval-resume.sql +20 -0
  85. package/src/migrations/078-subagent-threads.sql +241 -0
  86. package/src/migrations/079-subagent-questions.sql +6 -0
  87. package/src/migrations.ts +75 -1
  88. package/src/objects/README.md +7 -0
  89. package/src/objects/conditional-reader.ts +27 -0
  90. package/src/objects/query-compiler.ts +36 -6
  91. package/src/objects/read-scope.ts +140 -23
  92. package/src/objects/reader.ts +47 -14
  93. package/src/objects/scoped-reader.ts +103 -0
  94. package/src/objects/selected-reader.ts +12 -75
  95. package/src/objects/storage.ts +16 -0
  96. package/src/ontology-storage/index.ts +3 -0
  97. package/src/ontology-storage/materialization-writer.ts +14 -0
  98. package/src/ontology-storage/materializations.ts +16 -1
  99. package/src/ontology-storage/unique-values.ts +187 -0
  100. 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,
@@ -110,10 +119,54 @@ export class SqliteAiUsageStorage implements AiUsageStorage {
110
119
  return aggregateExecutionRows(input.executionIds, rows)
111
120
  }
112
121
 
122
+ async recordModelCallFiles(input: RecordAiModelCallFilesInput): Promise<AiModelCallFilesRecord> {
123
+ const record = normalizeAiModelCallFilesRecord(input)
124
+ return runImmediateTransaction(this.connection.db, () => {
125
+ const existing = this.findCallFiles(record.projectId, record.callId)
126
+ if (existing) return assertSameCallExecution(existing, record)
127
+ this.assertExecutionExists(record.projectId, record.executionId)
128
+ this.connection.db
129
+ .query(
130
+ `
131
+ INSERT INTO ai_model_call_files (project_id, call_id, execution_id, files, recorded_at)
132
+ VALUES (?, ?, ?, ?, ?)
133
+ `
134
+ )
135
+ .run(
136
+ record.projectId,
137
+ record.callId,
138
+ record.executionId,
139
+ JSON.stringify(record.files),
140
+ record.recordedAt.toISOString()
141
+ )
142
+ return record
143
+ })
144
+ }
145
+
146
+ async getModelCallFiles(input: GetAiModelCallFilesInput): Promise<AiModelCallFilesRecord | null> {
147
+ assertNonBlankProjectId(input.projectId)
148
+ return this.findCallFiles(input.projectId, input.callId)
149
+ }
150
+
113
151
  close(): void {
114
152
  closeSqliteStoreConnection(this.connection)
115
153
  }
116
154
 
155
+ private findCallFiles(projectId: string, callId: string): AiModelCallFilesRecord | null {
156
+ const row = this.connection.db
157
+ .query("SELECT * FROM ai_model_call_files WHERE project_id = ? AND call_id = ?")
158
+ .get(projectId, callId) as CallFilesRow | null
159
+ return row
160
+ ? {
161
+ projectId: row.project_id,
162
+ executionId: row.execution_id,
163
+ callId: row.call_id,
164
+ files: JSON.parse(row.files) as FileRef[],
165
+ recordedAt: new Date(row.recorded_at),
166
+ }
167
+ : null
168
+ }
169
+
117
170
  private insertRecord(record: AiModelCallUsageRecord): void {
118
171
  this.connection.db
119
172
  .query(
@@ -140,12 +193,16 @@ export class SqliteAiUsageStorage implements AiUsageStorage {
140
193
  text_output_tokens,
141
194
  reasoning_output_tokens,
142
195
  audio_duration_ms,
196
+ image_output_tokens,
197
+ audio_output_tokens,
198
+ output_images,
199
+ input_characters,
143
200
  reporting_status,
144
201
  raw_usage,
145
202
  occurred_at,
146
203
  recorded_at
147
204
  ) VALUES (
148
- ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
205
+ ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
149
206
  )
150
207
  `
151
208
  )
@@ -171,6 +228,10 @@ export class SqliteAiUsageStorage implements AiUsageStorage {
171
228
  record.usage.textOutputTokens ?? null,
172
229
  record.usage.reasoningOutputTokens ?? null,
173
230
  record.usage.audioDurationMs ?? null,
231
+ record.usage.imageOutputTokens ?? null,
232
+ record.usage.audioOutputTokens ?? null,
233
+ record.usage.outputImages ?? null,
234
+ record.usage.inputCharacters ?? null,
174
235
  record.usage.reportingStatus,
175
236
  record.rawUsage === undefined ? null : JSON.stringify(record.rawUsage),
176
237
  record.occurredAt.toISOString(),
@@ -285,6 +346,14 @@ export class SqliteAiUsageStorage implements AiUsageStorage {
285
346
  }
286
347
  }
287
348
 
349
+ interface CallFilesRow {
350
+ readonly project_id: string
351
+ readonly call_id: string
352
+ readonly execution_id: string
353
+ readonly files: string
354
+ readonly recorded_at: string
355
+ }
356
+
288
357
  interface AiUsageRow {
289
358
  readonly project_id: string
290
359
  readonly id: string
@@ -307,6 +376,10 @@ interface AiUsageRow {
307
376
  readonly text_output_tokens: number | null
308
377
  readonly reasoning_output_tokens: number | null
309
378
  readonly audio_duration_ms: number | null
379
+ readonly image_output_tokens: number | null
380
+ readonly audio_output_tokens: number | null
381
+ readonly output_images: number | null
382
+ readonly input_characters: number | null
310
383
  readonly reporting_status: AiModelCallUsageRecord["usage"]["reportingStatus"]
311
384
  readonly raw_usage: string | null
312
385
  readonly occurred_at: string
@@ -349,6 +422,10 @@ function usageFromRow(row: AiUsageRow): AiModelCallUsageInput {
349
422
  ? {}
350
423
  : { reasoningOutputTokens: row.reasoning_output_tokens }),
351
424
  ...(row.audio_duration_ms === null ? {} : { audioDurationMs: row.audio_duration_ms }),
425
+ ...(row.image_output_tokens === null ? {} : { imageOutputTokens: row.image_output_tokens }),
426
+ ...(row.audio_output_tokens === null ? {} : { audioOutputTokens: row.audio_output_tokens }),
427
+ ...(row.output_images === null ? {} : { outputImages: row.output_images }),
428
+ ...(row.input_characters === null ? {} : { inputCharacters: row.input_characters }),
352
429
  }
353
430
  }
354
431
 
@@ -1,16 +1,23 @@
1
1
  import type { Database, SQLQueryBindings } from "bun:sqlite"
2
2
  import {
3
+ artifactStateBytes,
3
4
  artifactStorageErrors,
5
+ checkArtifactStateWrite,
4
6
  parseArtifactFrameGrantRow,
5
7
  parseArtifactRow,
8
+ parseArtifactStateRow,
6
9
  parseArtifactVersionRow,
7
10
  } from "@sixb/core/internal/storage"
8
11
  import type {
9
12
  ArtifactFrameGrantRecord,
10
13
  ArtifactRecord,
14
+ ArtifactStateEntry,
15
+ ArtifactStateKeyInput,
16
+ ArtifactStateScope,
11
17
  ArtifactStorage,
12
18
  ArtifactVersionInput,
13
19
  ArtifactVersionRecord,
20
+ CopyArtifactInput,
14
21
  CreateArtifactInput,
15
22
  DeleteArtifactInput,
16
23
  DeleteArtifactVersionsByOriginInput,
@@ -25,6 +32,7 @@ import type {
25
32
  ListReferencingVersionsInput,
26
33
  PublishArtifactVersionInput,
27
34
  PublishedArtifactVersion,
35
+ SetArtifactStateInput,
28
36
  } from "@sixb/core/storage"
29
37
  import { runImmediateTransaction, type SqliteStoreConnection } from "./transactions"
30
38
 
@@ -45,6 +53,15 @@ const FRAME_GRANT_COLUMNS = `
45
53
  created_at AS createdAt, expires_at AS expiresAt
46
54
  `
47
55
 
56
+ const STATE_COLUMNS = "key, value, size_bytes AS sizeBytes, updated_at AS updatedAt"
57
+
58
+ /** One reader of one artifact: the bindings that follow `STATE_SCOPE`. */
59
+ const STATE_SCOPE = "project_id = ? AND artifact_id = ? AND principal_type = ? AND principal_id = ?"
60
+
61
+ function stateScope(input: ArtifactStateScope): SQLQueryBindings[] {
62
+ return [input.projectId, input.artifactId, input.principal.type, input.principal.id]
63
+ }
64
+
48
65
  type Row = Record<string, unknown>
49
66
 
50
67
  export class SqliteArtifactStorage implements ArtifactStorage {
@@ -88,16 +105,33 @@ export class SqliteArtifactStorage implements ArtifactStorage {
88
105
  return runImmediateTransaction(this.db, () => {
89
106
  const current = this.db
90
107
  .query(
91
- "SELECT kind, next_version AS nextVersion FROM artifacts WHERE project_id = ? AND id = ?"
108
+ `SELECT kind, next_version AS nextVersion, latest_version AS latestVersion
109
+ FROM artifacts WHERE project_id = ? AND id = ?`
92
110
  )
93
111
  .get(input.projectId, input.artifactId) as {
94
112
  readonly kind: ArtifactRecord["kind"]
95
113
  readonly nextVersion: number
114
+ readonly latestVersion: number
96
115
  } | null
97
116
  if (!current) throw artifactStorageErrors.notFound(input.artifactId)
98
117
  if (current.kind !== input.kind) {
99
118
  throw artifactStorageErrors.kindMismatch(input.artifactId, current.kind, input.kind)
100
119
  }
120
+ // Inside the write transaction: a publish from the same base after this one finds it moved.
121
+ if (input.baseVersion !== undefined && current.latestVersion !== input.baseVersion) {
122
+ const latest = parseArtifactVersionRow(
123
+ this.db
124
+ .query(
125
+ `SELECT ${VERSION_COLUMNS} FROM artifact_versions
126
+ WHERE project_id = ? AND artifact_id = ? AND version = ?`
127
+ )
128
+ .get(input.projectId, input.artifactId, current.latestVersion) as Row
129
+ )
130
+ throw artifactStorageErrors.moved(input.artifactId, input.baseVersion, {
131
+ version: latest.version,
132
+ author: latest.author,
133
+ })
134
+ }
101
135
  const version = this.insertVersion(
102
136
  input.projectId,
103
137
  input.artifactId,
@@ -137,14 +171,14 @@ export class SqliteArtifactStorage implements ArtifactStorage {
137
171
  where.push("a.owner_principal_type = ? AND a.owner_principal_id = ?")
138
172
  params.push(input.owner.type, input.owner.id)
139
173
  }
140
- if (input.originThreadId !== undefined) {
174
+ if (input.threadId !== undefined) {
141
175
  where.push(
142
176
  `EXISTS (
143
- SELECT 1 FROM artifact_versions v
144
- WHERE v.project_id = a.project_id AND v.artifact_id = a.id AND v.origin_thread_id = ?
177
+ SELECT 1 FROM artifact_threads t
178
+ WHERE t.project_id = a.project_id AND t.artifact_id = a.id AND t.thread_id = ?
145
179
  )`
146
180
  )
147
- params.push(input.originThreadId)
181
+ params.push(input.threadId)
148
182
  }
149
183
  if (input.kind !== undefined) {
150
184
  where.push("a.kind = ?")
@@ -221,6 +255,70 @@ export class SqliteArtifactStorage implements ArtifactStorage {
221
255
  return rows.map(parseArtifactVersionRow)
222
256
  }
223
257
 
258
+ async listThreadIds(input: GetArtifactInput): Promise<string[]> {
259
+ const rows = this.db
260
+ .query(
261
+ `SELECT thread_id AS threadId FROM artifact_threads
262
+ WHERE project_id = ? AND artifact_id = ? ORDER BY thread_id`
263
+ )
264
+ .all(input.projectId, input.id) as { readonly threadId: string }[]
265
+ return rows.map((row) => row.threadId)
266
+ }
267
+
268
+ async copy(input: CopyArtifactInput): Promise<ArtifactRecord> {
269
+ return runImmediateTransaction(this.db, () => {
270
+ const source = this.db
271
+ .query("SELECT id FROM artifacts WHERE project_id = ? AND id = ?")
272
+ .get(input.projectId, input.sourceId)
273
+ if (!source) throw artifactStorageErrors.notFound(input.sourceId)
274
+
275
+ const artifact = this.db
276
+ .query(
277
+ `INSERT INTO artifacts (
278
+ project_id, id, kind, title, owner_principal_type, owner_principal_id,
279
+ latest_version, next_version, created_at, updated_at
280
+ )
281
+ SELECT project_id, ?, kind, title, ?, ?, latest_version, next_version, created_at,
282
+ updated_at
283
+ FROM artifacts WHERE project_id = ? AND id = ?
284
+ ON CONFLICT (project_id, id) DO NOTHING
285
+ RETURNING ${ARTIFACT_COLUMNS}`
286
+ )
287
+ .get(
288
+ input.id,
289
+ input.owner.type,
290
+ input.owner.id,
291
+ input.projectId,
292
+ input.sourceId
293
+ ) as Row | null
294
+ if (!artifact) throw artifactStorageErrors.exists(input.id)
295
+
296
+ this.db
297
+ .query(
298
+ `INSERT INTO artifact_versions (
299
+ project_id, artifact_id, version, title, entry, files, outline, refs,
300
+ author_principal_type, author_principal_id, created_at
301
+ )
302
+ SELECT project_id, ?, version, title, entry, files, outline, refs,
303
+ author_principal_type, author_principal_id, created_at
304
+ FROM artifact_versions WHERE project_id = ? AND artifact_id = ?`
305
+ )
306
+ .run(input.id, input.projectId, input.sourceId)
307
+ this.db
308
+ .query(
309
+ `INSERT INTO artifact_threads (project_id, artifact_id, thread_id, shared_at, copied)
310
+ VALUES (?, ?, ?, ?, 1)`
311
+ )
312
+ .run(
313
+ input.projectId,
314
+ input.id,
315
+ input.threadId,
316
+ (input.copiedAt ?? new Date()).toISOString()
317
+ )
318
+ return parseArtifactRow(artifact)
319
+ })
320
+ }
321
+
224
322
  async createFrameGrant(record: ArtifactFrameGrantRecord): Promise<void> {
225
323
  runImmediateTransaction(this.db, () => {
226
324
  const exists = this.db
@@ -269,8 +367,65 @@ export class SqliteArtifactStorage implements ArtifactStorage {
269
367
  .run(input.projectId, input.expiredBefore.toISOString(), input.limit).changes
270
368
  }
271
369
 
370
+ async getState(input: ArtifactStateKeyInput): Promise<ArtifactStateEntry | null> {
371
+ const row = this.db
372
+ .query(`SELECT ${STATE_COLUMNS} FROM artifact_state WHERE ${STATE_SCOPE} AND key = ?`)
373
+ .get(...stateScope(input), input.key) as Row | null
374
+ return row ? parseArtifactStateRow(row) : null
375
+ }
376
+
377
+ async setState(input: SetArtifactStateInput): Promise<void> {
378
+ runImmediateTransaction(this.db, () => {
379
+ const artifact = this.db
380
+ .query("SELECT 1 FROM artifacts WHERE project_id = ? AND id = ?")
381
+ .get(input.projectId, input.artifactId)
382
+ if (!artifact) throw artifactStorageErrors.notFound(input.artifactId)
383
+ // In the write transaction: two writes at once cannot both fit under the limits.
384
+ const others = this.db
385
+ .query(
386
+ `SELECT count(*) AS keys, coalesce(sum(size_bytes), 0) AS bytes FROM artifact_state
387
+ WHERE ${STATE_SCOPE} AND key <> ?`
388
+ )
389
+ .get(...stateScope(input), input.key) as { readonly keys: number; readonly bytes: number }
390
+ const sizeBytes = artifactStateBytes(input.value)
391
+ checkArtifactStateWrite({ key: input.key, valueBytes: sizeBytes, others })
392
+ this.db
393
+ .query(
394
+ `INSERT INTO artifact_state (
395
+ project_id, artifact_id, principal_type, principal_id, key, value, size_bytes,
396
+ updated_at
397
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
398
+ ON CONFLICT (project_id, artifact_id, principal_type, principal_id, key) DO UPDATE SET
399
+ value = excluded.value, size_bytes = excluded.size_bytes,
400
+ updated_at = excluded.updated_at`
401
+ )
402
+ .run(...stateScope(input), input.key, input.value, sizeBytes, input.updatedAt.toISOString())
403
+ })
404
+ }
405
+
406
+ async deleteState(input: ArtifactStateKeyInput): Promise<boolean> {
407
+ return (
408
+ this.db
409
+ .query(`DELETE FROM artifact_state WHERE ${STATE_SCOPE} AND key = ?`)
410
+ .run(...stateScope(input), input.key).changes > 0
411
+ )
412
+ }
413
+
414
+ async listState(input: ArtifactStateScope): Promise<ArtifactStateEntry[]> {
415
+ const rows = this.db
416
+ .query(`SELECT ${STATE_COLUMNS} FROM artifact_state WHERE ${STATE_SCOPE} ORDER BY key`)
417
+ .all(...stateScope(input)) as Row[]
418
+ return rows.map(parseArtifactStateRow)
419
+ }
420
+
421
+ async clearState(input: ArtifactStateScope): Promise<number> {
422
+ return this.db
423
+ .query(`DELETE FROM artifact_state WHERE ${STATE_SCOPE}`)
424
+ .run(...stateScope(input)).changes
425
+ }
426
+
272
427
  async delete(input: DeleteArtifactInput): Promise<boolean> {
273
- // Versions and frame grants go with the artifact through ON DELETE CASCADE.
428
+ // Versions, frame grants, and state go with the artifact through ON DELETE CASCADE.
274
429
  return (
275
430
  this.db
276
431
  .query("DELETE FROM artifacts WHERE project_id = ? AND id = ?")
@@ -287,12 +442,35 @@ export class SqliteArtifactStorage implements ArtifactStorage {
287
442
  )
288
443
  .all(input.projectId, input.runId) as { readonly artifactId: string }[]
289
444
  const deleted = this.db
290
- .query("DELETE FROM artifact_versions WHERE project_id = ? AND origin_run_id = ?")
291
- .run(input.projectId, input.runId).changes
445
+ .query(
446
+ `DELETE FROM artifact_versions WHERE project_id = ? AND origin_run_id = ?
447
+ RETURNING origin_thread_id AS threadId`
448
+ )
449
+ .all(input.projectId, input.runId) as { readonly threadId: string }[]
450
+ // A run publishes from one thread: its artifacts stay shared there only while a version
451
+ // from it remains, or when they were copied for it.
452
+ const threadId = deleted[0]?.threadId
292
453
  for (const { artifactId } of affected) {
454
+ this.db
455
+ .query(
456
+ `DELETE FROM artifact_threads
457
+ WHERE project_id = ? AND artifact_id = ? AND thread_id = ? AND copied = 0
458
+ AND NOT EXISTS (
459
+ SELECT 1 FROM artifact_versions
460
+ WHERE project_id = ? AND artifact_id = ? AND origin_thread_id = ?
461
+ )`
462
+ )
463
+ .run(
464
+ input.projectId,
465
+ artifactId,
466
+ threadId ?? null,
467
+ input.projectId,
468
+ artifactId,
469
+ threadId ?? null
470
+ )
293
471
  this.repointLatest(input.projectId, artifactId)
294
472
  }
295
- return deleted
473
+ return deleted.length
296
474
  })
297
475
  }
298
476
 
@@ -351,6 +529,25 @@ export class SqliteArtifactStorage implements ArtifactStorage {
351
529
  input.origin?.runId ?? null,
352
530
  input.createdAt.toISOString()
353
531
  ) as Row
532
+ if (input.origin) {
533
+ this.linkThread(projectId, artifactId, input.origin.threadId, input.createdAt)
534
+ }
354
535
  return parseArtifactVersionRow(row)
355
536
  }
537
+
538
+ /** Shares an artifact in a thread, keeping when it was first shared there. */
539
+ private linkThread(
540
+ projectId: string,
541
+ artifactId: string,
542
+ threadId: string,
543
+ sharedAt: Date
544
+ ): void {
545
+ this.db
546
+ .query(
547
+ `INSERT INTO artifact_threads (project_id, artifact_id, thread_id, shared_at)
548
+ VALUES (?, ?, ?, ?)
549
+ ON CONFLICT (project_id, artifact_id, thread_id) DO NOTHING`
550
+ )
551
+ .run(projectId, artifactId, threadId, sharedAt.toISOString())
552
+ }
356
553
  }
@@ -12,6 +12,7 @@ import type {
12
12
  InvitationRecord,
13
13
  SessionRecord,
14
14
  SuspendUserAndRevokeSessionsInput,
15
+ UpdateAuthUserResult,
15
16
  UserIdentityRecord,
16
17
  UserRecord,
17
18
  } from "@sixb/core/storage"
@@ -32,7 +33,7 @@ import { SqliteAuthUserIdentityStore } from "./identities"
32
33
  import { SqliteAuthInvitationStore } from "./invitations"
33
34
  import { SqliteAuthMagicLinkStore } from "./magic-links"
34
35
  import { SqliteAuthOidcAuthorizationAttemptStore } from "./oidc-attempts"
35
- import type { SqliteAuthMagicLinkRow, SqliteAuthOidcAttemptRow } from "./rows"
36
+ import type { SqliteAuthMagicLinkRow, SqliteAuthOidcAttemptRow, SqliteAuthUserRow } from "./rows"
36
37
  import { rowToIdentityRecord, rowToUserRecord, serializeOptionalRecord } from "./rows"
37
38
  import { SqliteAuthServiceAccountGroupMembershipStore } from "./service-account-group-memberships"
38
39
  import { SqliteAuthServiceAccountStore } from "./service-accounts"
@@ -216,16 +217,17 @@ export class SqliteAuthStorage implements AuthStorage {
216
217
  consumedAt: completedAt,
217
218
  })
218
219
 
219
- const user = existingUserRow
220
- ? rowToUserRecord(existingUserRow)
221
- : this.insertUser({
222
- id: newUserId ?? assertNonEmpty(input.newUserId, "User id"),
223
- projectId,
224
- email: magicLink.email,
225
- displayName: input.newUserDisplayName,
226
- avatarUrl: input.newUserAvatarUrl,
227
- createdAt: completedAt,
228
- })
220
+ const previousUser = existingUserRow ? rowToUserRecord(existingUserRow) : null
221
+ const user =
222
+ previousUser ??
223
+ this.insertUser({
224
+ id: newUserId ?? assertNonEmpty(input.newUserId, "User id"),
225
+ projectId,
226
+ email: magicLink.email,
227
+ displayName: input.newUserDisplayName,
228
+ avatarUrl: input.newUserAvatarUrl,
229
+ createdAt: completedAt,
230
+ })
229
231
 
230
232
  const invitation = this.acceptInvitationAndApplyGroups({
231
233
  activeInvitation,
@@ -253,6 +255,7 @@ export class SqliteAuthStorage implements AuthStorage {
253
255
  session,
254
256
  invitation: invitation.invitation,
255
257
  groupMemberships,
258
+ previousUser,
256
259
  }
257
260
  }
258
261
  )
@@ -297,7 +300,7 @@ export class SqliteAuthStorage implements AuthStorage {
297
300
  email,
298
301
  now: completedAt,
299
302
  })
300
- let userRow = identity
303
+ const userRow = identity
301
304
  ? getUserRowById(this.db, { projectId, id: identity.user_id })
302
305
  : getUserRowByEmail(this.db, { projectId, email })
303
306
  const shouldCreateUser = !identity && !userRow
@@ -348,8 +351,10 @@ export class SqliteAuthStorage implements AuthStorage {
348
351
 
349
352
  this.consumeOidcAttempt(input, completedAt, projectId)
350
353
 
354
+ const previousUser = userRow ? rowToUserRecord(userRow) : null
355
+ let user: UserRecord
351
356
  if (!userRow) {
352
- const user = this.insertUser({
357
+ user = this.insertUser({
353
358
  id: newUserId ?? assertNonEmpty(input.newUserId, "User id"),
354
359
  projectId,
355
360
  email,
@@ -357,19 +362,10 @@ export class SqliteAuthStorage implements AuthStorage {
357
362
  avatarUrl: input.avatarUrl,
358
363
  createdAt: completedAt,
359
364
  })
360
- userRow = {
361
- project_id: user.projectId,
362
- id: user.id,
363
- email: user.email,
364
- display_name: user.displayName ?? null,
365
- avatar_url: user.avatarUrl ?? null,
366
- status: user.status,
367
- created_at: toIso(user.createdAt),
368
- updated_at: toIso(user.updatedAt),
369
- }
365
+ } else {
366
+ user = this.refreshProviderProfile(userRow, input, completedAt)
370
367
  }
371
368
 
372
- const user = rowToUserRecord(userRow)
373
369
  const invitation = this.acceptInvitationAndApplyGroups({
374
370
  activeInvitation,
375
371
  completedAt,
@@ -412,6 +408,7 @@ export class SqliteAuthStorage implements AuthStorage {
412
408
  identity: nextIdentity,
413
409
  invitation: invitation.invitation,
414
410
  groupMemberships,
411
+ previousUser,
415
412
  }
416
413
  }
417
414
  )
@@ -425,7 +422,7 @@ export class SqliteAuthStorage implements AuthStorage {
425
422
 
426
423
  async suspendUserAndRevokeSessions(
427
424
  input: SuspendUserAndRevokeSessionsInput
428
- ): Promise<UserRecord> {
425
+ ): Promise<UpdateAuthUserResult> {
429
426
  return runImmediateTransaction(this.db, () => {
430
427
  const existing = getUserRowById(this.db, {
431
428
  projectId: input.projectId,
@@ -456,11 +453,12 @@ export class SqliteAuthStorage implements AuthStorage {
456
453
  revokedAt: input.suspendedAt,
457
454
  })
458
455
 
459
- return rowToUserRecord({
456
+ const user = rowToUserRecord({
460
457
  ...existing,
461
458
  status: "suspended",
462
459
  updated_at: toIso(input.suspendedAt),
463
460
  })
461
+ return { user, previousUser: rowToUserRecord(existing) }
464
462
  })
465
463
  }
466
464
 
@@ -526,6 +524,40 @@ export class SqliteAuthStorage implements AuthStorage {
526
524
  }
527
525
  }
528
526
 
527
+ // The identity provider owns the name and picture it sends: every sign-in overwrites them, and
528
+ // one it leaves out keeps the stored value.
529
+ private refreshProviderProfile(
530
+ row: SqliteAuthUserRow,
531
+ input: CompleteOidcSignInInput,
532
+ updatedAt: Date
533
+ ): UserRecord {
534
+ const displayName = input.displayName ?? row.display_name
535
+ const avatarUrl = input.avatarUrl ?? row.avatar_url
536
+ if (displayName === row.display_name && avatarUrl === row.avatar_url) {
537
+ return rowToUserRecord(row)
538
+ }
539
+
540
+ this.db
541
+ .query(
542
+ `
543
+ UPDATE auth_users
544
+ SET display_name = ?,
545
+ avatar_url = ?,
546
+ updated_at = ?
547
+ WHERE project_id = ?
548
+ AND id = ?
549
+ `
550
+ )
551
+ .run(displayName, avatarUrl, toIso(updatedAt), row.project_id, row.id)
552
+
553
+ return rowToUserRecord({
554
+ ...row,
555
+ display_name: displayName,
556
+ avatar_url: avatarUrl,
557
+ updated_at: toIso(updatedAt),
558
+ })
559
+ }
560
+
529
561
  private insertUser(input: {
530
562
  readonly id: string
531
563
  readonly projectId: string