@sixb/pg 0.0.1

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 (152) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +73 -0
  3. package/dist/agents/agent-storage.d.ts +20 -0
  4. package/dist/agents/agent-storage.d.ts.map +1 -0
  5. package/dist/agents/index.d.ts +6 -0
  6. package/dist/agents/index.d.ts.map +1 -0
  7. package/dist/agents/messages.d.ts +13 -0
  8. package/dist/agents/messages.d.ts.map +1 -0
  9. package/dist/agents/rows.d.ts +77 -0
  10. package/dist/agents/rows.d.ts.map +1 -0
  11. package/dist/agents/runs.d.ts +25 -0
  12. package/dist/agents/runs.d.ts.map +1 -0
  13. package/dist/agents/threads.d.ts +13 -0
  14. package/dist/agents/threads.d.ts.map +1 -0
  15. package/dist/auth-storage/access-tokens.d.ts +33 -0
  16. package/dist/auth-storage/access-tokens.d.ts.map +1 -0
  17. package/dist/auth-storage/auth-storage.d.ts +41 -0
  18. package/dist/auth-storage/auth-storage.d.ts.map +1 -0
  19. package/dist/auth-storage/group-memberships.d.ts +21 -0
  20. package/dist/auth-storage/group-memberships.d.ts.map +1 -0
  21. package/dist/auth-storage/identities.d.ts +17 -0
  22. package/dist/auth-storage/identities.d.ts.map +1 -0
  23. package/dist/auth-storage/index.d.ts +3 -0
  24. package/dist/auth-storage/index.d.ts.map +1 -0
  25. package/dist/auth-storage/invitations.d.ts +29 -0
  26. package/dist/auth-storage/invitations.d.ts.map +1 -0
  27. package/dist/auth-storage/magic-links.d.ts +28 -0
  28. package/dist/auth-storage/magic-links.d.ts.map +1 -0
  29. package/dist/auth-storage/oidc-attempts.d.ts +18 -0
  30. package/dist/auth-storage/oidc-attempts.d.ts.map +1 -0
  31. package/dist/auth-storage/rows.d.ts +138 -0
  32. package/dist/auth-storage/rows.d.ts.map +1 -0
  33. package/dist/auth-storage/service-account-group-memberships.d.ts +17 -0
  34. package/dist/auth-storage/service-account-group-memberships.d.ts.map +1 -0
  35. package/dist/auth-storage/service-accounts.d.ts +14 -0
  36. package/dist/auth-storage/service-accounts.d.ts.map +1 -0
  37. package/dist/auth-storage/sessions.d.ts +55 -0
  38. package/dist/auth-storage/sessions.d.ts.map +1 -0
  39. package/dist/auth-storage/shared.d.ts +159 -0
  40. package/dist/auth-storage/shared.d.ts.map +1 -0
  41. package/dist/auth-storage/users.d.ts +19 -0
  42. package/dist/auth-storage/users.d.ts.map +1 -0
  43. package/dist/index.d.ts +127 -0
  44. package/dist/index.d.ts.map +1 -0
  45. package/dist/index.js +11957 -0
  46. package/dist/index.js.map +55 -0
  47. package/dist/latest-run-query.d.ts +24 -0
  48. package/dist/latest-run-query.d.ts.map +1 -0
  49. package/dist/migrations.d.ts +20 -0
  50. package/dist/migrations.d.ts.map +1 -0
  51. package/dist/ontology-storage/commits.d.ts +11 -0
  52. package/dist/ontology-storage/commits.d.ts.map +1 -0
  53. package/dist/ontology-storage/index.d.ts +22 -0
  54. package/dist/ontology-storage/index.d.ts.map +1 -0
  55. package/dist/ontology-storage/materialization-session.d.ts +63 -0
  56. package/dist/ontology-storage/materialization-session.d.ts.map +1 -0
  57. package/dist/ontology-storage/materialization-state.d.ts +69 -0
  58. package/dist/ontology-storage/materialization-state.d.ts.map +1 -0
  59. package/dist/ontology-storage/materialization-writer.d.ts +15 -0
  60. package/dist/ontology-storage/materialization-writer.d.ts.map +1 -0
  61. package/dist/ontology-storage/materializations.d.ts +33 -0
  62. package/dist/ontology-storage/materializations.d.ts.map +1 -0
  63. package/dist/ontology-storage/outbox.d.ts +12 -0
  64. package/dist/ontology-storage/outbox.d.ts.map +1 -0
  65. package/dist/ontology-storage/shared.d.ts +95 -0
  66. package/dist/ontology-storage/shared.d.ts.map +1 -0
  67. package/dist/ontology-storage/sources.d.ts +25 -0
  68. package/dist/ontology-storage/sources.d.ts.map +1 -0
  69. package/dist/pg-action-run-storage.d.ts +21 -0
  70. package/dist/pg-action-run-storage.d.ts.map +1 -0
  71. package/dist/pg-client.d.ts +58 -0
  72. package/dist/pg-client.d.ts.map +1 -0
  73. package/dist/pg-object-query-compiler.d.ts +55 -0
  74. package/dist/pg-object-query-compiler.d.ts.map +1 -0
  75. package/dist/pg-object-storage.d.ts +90 -0
  76. package/dist/pg-object-storage.d.ts.map +1 -0
  77. package/dist/pg-pipeline-run-storage.d.ts +18 -0
  78. package/dist/pg-pipeline-run-storage.d.ts.map +1 -0
  79. package/dist/pg-projection-run-storage.d.ts +19 -0
  80. package/dist/pg-projection-run-storage.d.ts.map +1 -0
  81. package/dist/pg-rules-storage.d.ts +25 -0
  82. package/dist/pg-rules-storage.d.ts.map +1 -0
  83. package/dist/pg-sync-run-storage.d.ts +15 -0
  84. package/dist/pg-sync-run-storage.d.ts.map +1 -0
  85. package/dist/pg-timeseries-storage.d.ts +32 -0
  86. package/dist/pg-timeseries-storage.d.ts.map +1 -0
  87. package/dist/pg-webhook-delivery-storage.d.ts +17 -0
  88. package/dist/pg-webhook-delivery-storage.d.ts.map +1 -0
  89. package/dist/pg-webhook-run-storage.d.ts +14 -0
  90. package/dist/pg-webhook-run-storage.d.ts.map +1 -0
  91. package/dist/pg-workflow-intervention-storage.d.ts +16 -0
  92. package/dist/pg-workflow-intervention-storage.d.ts.map +1 -0
  93. package/dist/pg-workflow-run-storage.d.ts +49 -0
  94. package/dist/pg-workflow-run-storage.d.ts.map +1 -0
  95. package/dist/run-list-query.d.ts +30 -0
  96. package/dist/run-list-query.d.ts.map +1 -0
  97. package/dist/storage-errors.d.ts +16 -0
  98. package/dist/storage-errors.d.ts.map +1 -0
  99. package/dist/transactions.d.ts +10 -0
  100. package/dist/transactions.d.ts.map +1 -0
  101. package/dist/tsconfig.json +5 -0
  102. package/package.json +60 -0
  103. package/src/agents/agent-storage.ts +26 -0
  104. package/src/agents/index.ts +5 -0
  105. package/src/agents/messages.ts +144 -0
  106. package/src/agents/rows.ts +227 -0
  107. package/src/agents/runs.ts +330 -0
  108. package/src/agents/threads.ts +116 -0
  109. package/src/auth-storage/access-tokens.ts +254 -0
  110. package/src/auth-storage/auth-storage.ts +798 -0
  111. package/src/auth-storage/group-memberships.ts +47 -0
  112. package/src/auth-storage/identities.ts +85 -0
  113. package/src/auth-storage/index.ts +2 -0
  114. package/src/auth-storage/invitations.ts +280 -0
  115. package/src/auth-storage/magic-links.ts +140 -0
  116. package/src/auth-storage/oidc-attempts.ts +93 -0
  117. package/src/auth-storage/rows.ts +352 -0
  118. package/src/auth-storage/service-account-group-memberships.ts +150 -0
  119. package/src/auth-storage/service-accounts.ts +154 -0
  120. package/src/auth-storage/sessions.ts +193 -0
  121. package/src/auth-storage/shared.ts +799 -0
  122. package/src/auth-storage/users.ts +196 -0
  123. package/src/index.ts +367 -0
  124. package/src/latest-run-query.ts +42 -0
  125. package/src/migrations/001-initial-schema.sql +1195 -0
  126. package/src/migrations.ts +280 -0
  127. package/src/ontology-storage/commits.ts +115 -0
  128. package/src/ontology-storage/index.ts +35 -0
  129. package/src/ontology-storage/materialization-session.ts +426 -0
  130. package/src/ontology-storage/materialization-state.ts +1109 -0
  131. package/src/ontology-storage/materialization-writer.ts +509 -0
  132. package/src/ontology-storage/materializations.ts +939 -0
  133. package/src/ontology-storage/outbox.ts +216 -0
  134. package/src/ontology-storage/shared.ts +293 -0
  135. package/src/ontology-storage/sources.ts +658 -0
  136. package/src/pg-action-run-storage.ts +657 -0
  137. package/src/pg-client.ts +124 -0
  138. package/src/pg-object-query-compiler.ts +1545 -0
  139. package/src/pg-object-storage.ts +638 -0
  140. package/src/pg-pipeline-run-storage.ts +457 -0
  141. package/src/pg-projection-run-storage.ts +526 -0
  142. package/src/pg-rules-storage.ts +308 -0
  143. package/src/pg-sync-run-storage.ts +263 -0
  144. package/src/pg-timeseries-storage.ts +180 -0
  145. package/src/pg-webhook-delivery-storage.ts +180 -0
  146. package/src/pg-webhook-run-storage.ts +185 -0
  147. package/src/pg-workflow-intervention-storage.ts +339 -0
  148. package/src/pg-workflow-run-storage.ts +1065 -0
  149. package/src/run-list-query.ts +108 -0
  150. package/src/sql.d.ts +4 -0
  151. package/src/storage-errors.ts +72 -0
  152. package/src/transactions.ts +58 -0
@@ -0,0 +1,308 @@
1
+ import type { RuleEventSubject } from "@sixb/core"
2
+ import type {
3
+ ListActiveRuleStatesInput,
4
+ ListActiveRuleStatesResult,
5
+ ListRuleStatesReconciliationPageInput,
6
+ ListRuleStatesReconciliationPageResult,
7
+ RuleStateRecord,
8
+ RuleStateTransitionEvent,
9
+ RulesStorage,
10
+ StoredRuleResolvedEvent,
11
+ StoredRuleTriggeredEvent,
12
+ } from "@sixb/core/storage"
13
+ import type { SqlParameter } from "./pg-client"
14
+ import type { PgStoreClient } from "./transactions"
15
+
16
+ export class PgRulesStorage implements RulesStorage {
17
+ constructor(private readonly sql: PgStoreClient) {}
18
+
19
+ async getActive(params: {
20
+ projectId: string
21
+ ruleId: string
22
+ subject: RuleEventSubject
23
+ }): Promise<RuleStateRecord | null> {
24
+ const [row] = await this.sql<RuleStateRow[]>`
25
+ SELECT
26
+ project_id,
27
+ rule_id,
28
+ subject_kind,
29
+ object_type_id,
30
+ primary_id,
31
+ triggered_at
32
+ FROM rule_states
33
+ WHERE project_id = ${params.projectId}
34
+ AND rule_id = ${params.ruleId}
35
+ AND subject_kind = ${params.subject.kind}
36
+ AND object_type_id = ${params.subject.objectTypeId}
37
+ AND primary_id = ${params.subject.primaryId}
38
+ `
39
+
40
+ return row ? rowToRuleStateRecord(row) : null
41
+ }
42
+
43
+ async getActiveBatch(params: {
44
+ projectId: string
45
+ items: readonly {
46
+ readonly ruleId: string
47
+ readonly subject: RuleEventSubject
48
+ }[]
49
+ }): Promise<readonly RuleStateRecord[]> {
50
+ if (params.items.length === 0) return []
51
+ const requested = JSON.stringify(
52
+ params.items.map((item) => ({
53
+ rule_id: item.ruleId,
54
+ subject_kind: item.subject.kind,
55
+ object_type_id: item.subject.objectTypeId,
56
+ primary_id: item.subject.primaryId,
57
+ }))
58
+ )
59
+ const rows = await this.sql<RuleStateRow[]>`
60
+ WITH requested AS (
61
+ SELECT *
62
+ FROM jsonb_to_recordset(${requested}::text::jsonb) AS identity(
63
+ rule_id text,
64
+ subject_kind text,
65
+ object_type_id text,
66
+ primary_id text
67
+ )
68
+ )
69
+ SELECT project_id, rule_id, subject_kind, object_type_id, primary_id, triggered_at
70
+ FROM rule_states AS states
71
+ JOIN requested USING (rule_id, subject_kind, object_type_id, primary_id)
72
+ WHERE states.project_id = ${params.projectId}
73
+ `
74
+ return rows.map(rowToRuleStateRecord)
75
+ }
76
+
77
+ async listActive(input: ListActiveRuleStatesInput): Promise<ListActiveRuleStatesResult> {
78
+ if (input.objectTypeIds?.length === 0) {
79
+ return { states: [], hasMore: false, total: 0 }
80
+ }
81
+
82
+ const whereClauses = ["project_id = $1"]
83
+ const params: SqlParameter[] = [input.projectId]
84
+ let index = 2
85
+
86
+ if (input.ruleId) {
87
+ whereClauses.push(`rule_id = $${index++}`)
88
+ params.push(input.ruleId)
89
+ }
90
+
91
+ if (input.objectTypeId) {
92
+ whereClauses.push(`object_type_id = $${index++}`)
93
+ params.push(input.objectTypeId)
94
+ }
95
+
96
+ if (input.objectTypeIds) {
97
+ const placeholders = input.objectTypeIds.map(() => `$${index++}`)
98
+ whereClauses.push(`object_type_id IN (${placeholders.join(", ")})`)
99
+ params.push(...input.objectTypeIds)
100
+ }
101
+
102
+ if (input.primaryId) {
103
+ whereClauses.push(`primary_id = $${index++}`)
104
+ params.push(input.primaryId)
105
+ }
106
+
107
+ const where = `WHERE ${whereClauses.join(" AND ")}`
108
+ const order = input.order === "asc" ? "ASC" : "DESC"
109
+ const offset = input.offset ?? 0
110
+
111
+ const [totalRow] = await this.sql.unsafe<{ count: string | number }[]>(
112
+ `SELECT COUNT(*)::bigint AS count FROM rule_states ${where}`,
113
+ params
114
+ )
115
+
116
+ const queryParams = [...params]
117
+ let query = `
118
+ SELECT
119
+ project_id,
120
+ rule_id,
121
+ subject_kind,
122
+ object_type_id,
123
+ primary_id,
124
+ triggered_at
125
+ FROM rule_states
126
+ ${where}
127
+ ORDER BY triggered_at ${order}, rule_id ${order}, object_type_id ${order}, primary_id ${order}
128
+ `
129
+
130
+ if (input.limit !== undefined) {
131
+ query += ` LIMIT $${index++} OFFSET $${index++}`
132
+ queryParams.push(input.limit, offset)
133
+ } else if (offset > 0) {
134
+ query += ` OFFSET $${index++}`
135
+ queryParams.push(offset)
136
+ }
137
+
138
+ const rows = await this.sql.unsafe<RuleStateRow[]>(query, queryParams)
139
+ const total = Number(totalRow?.count ?? 0)
140
+ const states = rows.map(rowToRuleStateRecord)
141
+
142
+ return {
143
+ states,
144
+ hasMore: offset + states.length < total,
145
+ total,
146
+ }
147
+ }
148
+
149
+ async listReconciliationPage(
150
+ input: ListRuleStatesReconciliationPageInput
151
+ ): Promise<ListRuleStatesReconciliationPageResult> {
152
+ assertPositiveLimit(input.limit)
153
+ const params: SqlParameter[] = [input.projectId]
154
+ const cursor = input.after ? "AND (rule_id, object_type_id, primary_id) > ($2, $3, $4)" : ""
155
+ if (input.after) {
156
+ params.push(input.after.ruleId, input.after.objectTypeId, input.after.primaryId)
157
+ }
158
+ params.push(input.limit + 1)
159
+ const limitParameter = `$${params.length}`
160
+ const rows = await this.sql.unsafe<RuleStateRow[]>(
161
+ `
162
+ SELECT project_id, rule_id, subject_kind, object_type_id, primary_id, triggered_at
163
+ FROM rule_states
164
+ WHERE project_id = $1 AND subject_kind = 'object' ${cursor}
165
+ ORDER BY rule_id ASC, object_type_id ASC, primary_id ASC
166
+ LIMIT ${limitParameter}
167
+ `,
168
+ params
169
+ )
170
+ return reconciliationPage(rows, input.limit)
171
+ }
172
+
173
+ async applyTriggered(event: StoredRuleTriggeredEvent): Promise<void> {
174
+ await this.sql`
175
+ INSERT INTO rule_states (
176
+ project_id,
177
+ rule_id,
178
+ subject_kind,
179
+ object_type_id,
180
+ primary_id,
181
+ triggered_at
182
+ ) VALUES (
183
+ ${event.projectId},
184
+ ${event.payload.ruleId},
185
+ ${event.payload.subject.kind},
186
+ ${event.payload.subject.objectTypeId},
187
+ ${event.payload.subject.primaryId},
188
+ ${event.payload.triggeredAt}
189
+ )
190
+ ON CONFLICT (project_id, rule_id, subject_kind, object_type_id, primary_id)
191
+ DO UPDATE SET triggered_at = excluded.triggered_at
192
+ `
193
+ }
194
+
195
+ async applyResolved(event: StoredRuleResolvedEvent): Promise<void> {
196
+ await this.sql`
197
+ DELETE FROM rule_states
198
+ WHERE project_id = ${event.projectId}
199
+ AND rule_id = ${event.payload.ruleId}
200
+ AND subject_kind = ${event.payload.subject.kind}
201
+ AND object_type_id = ${event.payload.subject.objectTypeId}
202
+ AND primary_id = ${event.payload.subject.primaryId}
203
+ `
204
+ }
205
+
206
+ async applyTransitions(events: readonly RuleStateTransitionEvent[]): Promise<void> {
207
+ if (events.length === 0) return
208
+ const rows = JSON.stringify(events.map(transitionRow))
209
+ await this.sql`
210
+ WITH transitions AS MATERIALIZED (
211
+ SELECT *
212
+ FROM jsonb_to_recordset(${rows}::text::jsonb) AS transition(
213
+ type text,
214
+ project_id text,
215
+ rule_id text,
216
+ subject_kind text,
217
+ object_type_id text,
218
+ primary_id text,
219
+ transition_at timestamptz
220
+ )
221
+ ), upserted AS (
222
+ INSERT INTO rule_states (
223
+ project_id, rule_id, subject_kind, object_type_id, primary_id, triggered_at
224
+ )
225
+ SELECT project_id, rule_id, subject_kind, object_type_id, primary_id, transition_at
226
+ FROM transitions
227
+ WHERE type = 'rule.triggered'
228
+ ON CONFLICT (project_id, rule_id, subject_kind, object_type_id, primary_id)
229
+ DO UPDATE SET triggered_at = excluded.triggered_at
230
+ RETURNING 1
231
+ )
232
+ DELETE FROM rule_states AS states
233
+ USING transitions
234
+ WHERE transitions.type = 'rule.resolved'
235
+ AND states.project_id = transitions.project_id
236
+ AND states.rule_id = transitions.rule_id
237
+ AND states.subject_kind = transitions.subject_kind
238
+ AND states.object_type_id = transitions.object_type_id
239
+ AND states.primary_id = transitions.primary_id
240
+ `
241
+ }
242
+ }
243
+
244
+ function transitionRow(event: RuleStateTransitionEvent) {
245
+ return {
246
+ type: event.type,
247
+ project_id: event.projectId,
248
+ rule_id: event.payload.ruleId,
249
+ subject_kind: event.payload.subject.kind,
250
+ object_type_id: event.payload.subject.objectTypeId,
251
+ primary_id: event.payload.subject.primaryId,
252
+ transition_at:
253
+ event.type === "rule.triggered" ? event.payload.triggeredAt : event.payload.resolvedAt,
254
+ }
255
+ }
256
+
257
+ function reconciliationPage(
258
+ rows: readonly RuleStateRow[],
259
+ limit: number
260
+ ): ListRuleStatesReconciliationPageResult {
261
+ const hasMore = rows.length > limit
262
+ const states = rows.slice(0, limit).map(rowToRuleStateRecord)
263
+ const last = states.at(-1)
264
+ return {
265
+ states,
266
+ ...(hasMore && last
267
+ ? {
268
+ next: {
269
+ ruleId: last.ruleId,
270
+ objectTypeId: last.subject.objectTypeId,
271
+ primaryId: last.subject.primaryId,
272
+ },
273
+ }
274
+ : {}),
275
+ }
276
+ }
277
+
278
+ function assertPositiveLimit(limit: number): void {
279
+ if (!Number.isSafeInteger(limit) || limit <= 0) {
280
+ throw new Error("Rules reconciliation page limit must be a positive safe integer.")
281
+ }
282
+ }
283
+
284
+ interface RuleStateRow {
285
+ readonly project_id: string
286
+ readonly rule_id: string
287
+ readonly subject_kind: RuleEventSubject["kind"]
288
+ readonly object_type_id: string
289
+ readonly primary_id: string
290
+ readonly triggered_at: Date | string
291
+ }
292
+
293
+ function rowToRuleStateRecord(row: RuleStateRow): RuleStateRecord {
294
+ return {
295
+ projectId: row.project_id,
296
+ ruleId: row.rule_id,
297
+ subject: {
298
+ kind: row.subject_kind,
299
+ objectTypeId: row.object_type_id,
300
+ primaryId: row.primary_id,
301
+ },
302
+ triggeredAt: toIsoString(row.triggered_at),
303
+ }
304
+ }
305
+
306
+ function toIsoString(value: Date | string): string {
307
+ return value instanceof Date ? value.toISOString() : new Date(value).toISOString()
308
+ }
@@ -0,0 +1,263 @@
1
+ import type { JsonValue } from "@sixb/core"
2
+ import type {
3
+ FinishSyncRunInput,
4
+ ListLatestSyncRunsInput,
5
+ ListLatestSyncRunsResult,
6
+ ListSyncRunsInput,
7
+ ListSyncRunsResult,
8
+ StartSyncRunInput,
9
+ SyncRunFailure,
10
+ SyncRunRecord,
11
+ SyncRunStorage,
12
+ } from "@sixb/core/storage"
13
+ import { SyncRunError } from "@sixb/core/storage"
14
+ import { queryLatestRunsByOwnerId } from "./latest-run-query"
15
+ import type { SqlParameter } from "./pg-client"
16
+ import { appendRunListFilters, hasEmptyStatuses, queryRunList } from "./run-list-query"
17
+ import { isUniqueViolation } from "./storage-errors"
18
+ import { type PgStoreClient, runPgTransaction } from "./transactions"
19
+
20
+ export class PgSyncRunStorage implements SyncRunStorage {
21
+ constructor(private readonly sql: PgStoreClient) {}
22
+
23
+ async start(input: StartSyncRunInput): Promise<SyncRunRecord> {
24
+ try {
25
+ const [row] = await this.sql<DatabaseRow[]>`
26
+ INSERT INTO sync_runs (
27
+ project_id,
28
+ id,
29
+ sync_id,
30
+ dataset_id,
31
+ mode,
32
+ status,
33
+ started_at,
34
+ expected_latest_version_id,
35
+ commit_message
36
+ ) VALUES (
37
+ ${input.projectId},
38
+ ${input.id},
39
+ ${input.syncId},
40
+ ${input.datasetId},
41
+ ${input.mode},
42
+ ${"running"},
43
+ ${input.startedAt ?? new Date()},
44
+ ${input.expectedLatestVersionId ?? null},
45
+ ${input.commitMessage ?? null}
46
+ )
47
+ RETURNING *, checkpoint IS NOT NULL AS checkpoint_present
48
+ `
49
+
50
+ return rowToSyncRunRecord(row)
51
+ } catch (error) {
52
+ if (isUniqueViolation(error)) {
53
+ throw new SyncRunError(
54
+ `[SixbPg] Sync run '${input.id}' already exists for project '${input.projectId}'.`
55
+ )
56
+ }
57
+
58
+ throw error
59
+ }
60
+ }
61
+
62
+ async finish(input: FinishSyncRunInput): Promise<SyncRunRecord> {
63
+ return runPgTransaction(this.sql, async (tx) => {
64
+ const [existing] = await tx<DatabaseRow[]>`
65
+ SELECT *, checkpoint IS NOT NULL AS checkpoint_present FROM sync_runs
66
+ WHERE project_id = ${input.projectId} AND id = ${input.id}
67
+ `
68
+
69
+ if (!existing) {
70
+ throw new SyncRunError(
71
+ `[SixbPg] Sync run '${input.id}' not found for project '${input.projectId}'.`
72
+ )
73
+ }
74
+
75
+ if (input.status === "succeeded") {
76
+ if (input.output && input.output.datasetId !== existing.dataset_id) {
77
+ throw new SyncRunError(
78
+ `[SixbPg] Sync run '${input.id}' output dataset '${input.output.datasetId}' does not match '${existing.dataset_id}'.`
79
+ )
80
+ }
81
+ if (!input.output && (existing.mode !== "append" || input.rowsRead !== 0)) {
82
+ throw new SyncRunError(
83
+ `[SixbPg] Sync run '${input.id}' may omit its output only for an empty append.`
84
+ )
85
+ }
86
+ }
87
+
88
+ const [updated] =
89
+ input.status === "succeeded"
90
+ ? await tx<DatabaseRow[]>`
91
+ UPDATE sync_runs
92
+ SET
93
+ status = ${input.status},
94
+ finished_at = ${input.finishedAt ?? new Date()},
95
+ rows_read = ${input.rowsRead},
96
+ output_version_id = ${input.output?.versionId ?? null},
97
+ error_name = ${null},
98
+ error_message = ${null},
99
+ checkpoint = ${serializeCheckpoint(input.checkpoint)}::text::jsonb
100
+ WHERE project_id = ${input.projectId} AND id = ${input.id}
101
+ RETURNING *, checkpoint IS NOT NULL AS checkpoint_present
102
+ `
103
+ : await tx<DatabaseRow[]>`
104
+ UPDATE sync_runs
105
+ SET
106
+ status = ${input.status},
107
+ finished_at = ${input.finishedAt ?? new Date()},
108
+ rows_read = ${input.rowsRead ?? existing.rows_read ?? null},
109
+ output_version_id = ${null},
110
+ error_name = ${input.error?.name ?? null},
111
+ error_message = ${input.error?.message ?? null},
112
+ checkpoint = ${null}
113
+ WHERE project_id = ${input.projectId} AND id = ${input.id}
114
+ RETURNING *, checkpoint IS NOT NULL AS checkpoint_present
115
+ `
116
+
117
+ return rowToSyncRunRecord(updated)
118
+ })
119
+ }
120
+
121
+ async getById(params: { projectId: string; id: string }): Promise<SyncRunRecord | null> {
122
+ const [row] = await this.sql<DatabaseRow[]>`
123
+ SELECT *, checkpoint IS NOT NULL AS checkpoint_present FROM sync_runs
124
+ WHERE project_id = ${params.projectId} AND id = ${params.id}
125
+ `
126
+
127
+ return row ? rowToSyncRunRecord(row) : null
128
+ }
129
+
130
+ async list(input: ListSyncRunsInput): Promise<ListSyncRunsResult> {
131
+ if (hasEmptyStatuses(input) || input.syncIds?.length === 0) {
132
+ return {
133
+ runs: [],
134
+ hasMore: false,
135
+ total: 0,
136
+ }
137
+ }
138
+
139
+ const whereClauses = ["project_id = $1"]
140
+ const params: SqlParameter[] = [input.projectId]
141
+ let index = 2
142
+
143
+ if (input.syncId) {
144
+ whereClauses.push(`sync_id = $${index++}`)
145
+ params.push(input.syncId)
146
+ }
147
+
148
+ if (input.syncIds) {
149
+ const placeholders = input.syncIds.map(() => `$${index++}`)
150
+ whereClauses.push(`sync_id IN (${placeholders.join(", ")})`)
151
+ params.push(...input.syncIds)
152
+ }
153
+
154
+ if (input.datasetId) {
155
+ whereClauses.push(`dataset_id = $${index++}`)
156
+ params.push(input.datasetId)
157
+ }
158
+
159
+ index = appendRunListFilters(whereClauses, params, index, input)
160
+
161
+ const { rows, total, hasMore } = await queryRunList<DatabaseRow>({
162
+ sql: this.sql,
163
+ tableName: "sync_runs",
164
+ selectList: "*, checkpoint IS NOT NULL AS checkpoint_present",
165
+ whereClauses,
166
+ params,
167
+ nextIndex: index,
168
+ order: input.order,
169
+ limit: input.limit,
170
+ offset: input.offset,
171
+ })
172
+
173
+ return {
174
+ runs: rows.map(rowToSyncRunRecord),
175
+ hasMore,
176
+ total,
177
+ }
178
+ }
179
+
180
+ async listLatestBySyncIds(input: ListLatestSyncRunsInput): Promise<ListLatestSyncRunsResult> {
181
+ const rows = await queryLatestRunsByOwnerId<DatabaseRow>({
182
+ sql: this.sql,
183
+ tableName: "sync_runs",
184
+ ownerColumn: "sync_id",
185
+ ownerIds: input.syncIds,
186
+ projectId: input.projectId,
187
+ ownerIdFor: (row) => row.sync_id,
188
+ selectList: "*, checkpoint IS NOT NULL AS checkpoint_present",
189
+ })
190
+
191
+ return {
192
+ runs: rows.map(rowToSyncRunRecord),
193
+ }
194
+ }
195
+ }
196
+
197
+ function serializeCheckpoint(checkpoint: JsonValue | undefined): string | null {
198
+ return checkpoint !== undefined ? JSON.stringify(checkpoint) : null
199
+ }
200
+
201
+ function toSyncRunFailure(row: DatabaseRow): SyncRunFailure | undefined {
202
+ if (!row.error_message) {
203
+ return undefined
204
+ }
205
+
206
+ return {
207
+ name: row.error_name ?? undefined,
208
+ message: row.error_message,
209
+ }
210
+ }
211
+
212
+ function rowToSyncRunRecord(row: DatabaseRow): SyncRunRecord {
213
+ return {
214
+ id: row.id,
215
+ projectId: row.project_id,
216
+ syncId: row.sync_id,
217
+ datasetId: row.dataset_id,
218
+ mode: row.mode,
219
+ status: row.status,
220
+ startedAt: new Date(row.started_at),
221
+ finishedAt: row.finished_at ? new Date(row.finished_at) : undefined,
222
+ rowsRead: row.rows_read != null ? Number(row.rows_read) : undefined,
223
+ output: row.output_version_id
224
+ ? {
225
+ datasetId: row.dataset_id,
226
+ versionId: row.output_version_id,
227
+ }
228
+ : undefined,
229
+ expectedLatestVersionId: row.expected_latest_version_id ?? undefined,
230
+ commitMessage: row.commit_message ?? undefined,
231
+ error: toSyncRunFailure(row),
232
+ checkpoint: hasCheckpoint(row) ? row.checkpoint : undefined,
233
+ }
234
+ }
235
+
236
+ function hasCheckpoint(row: DatabaseRow): boolean {
237
+ return (
238
+ row.checkpoint_present === true ||
239
+ row.checkpoint_present === 1 ||
240
+ row.checkpoint_present === "1" ||
241
+ row.checkpoint_present === "t" ||
242
+ row.checkpoint_present === "true"
243
+ )
244
+ }
245
+
246
+ interface DatabaseRow {
247
+ project_id: string
248
+ id: string
249
+ sync_id: string
250
+ dataset_id: string
251
+ mode: SyncRunRecord["mode"]
252
+ status: SyncRunRecord["status"]
253
+ started_at: Date | string
254
+ finished_at: Date | string | null
255
+ rows_read: number | string | null
256
+ output_version_id: string | null
257
+ expected_latest_version_id: string | null
258
+ commit_message: string | null
259
+ error_name: string | null
260
+ error_message: string | null
261
+ checkpoint: JsonValue | null
262
+ checkpoint_present: boolean | number | string
263
+ }