@sixb/pg 0.1.7 → 0.1.9

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 (68) hide show
  1. package/dist/agents/rows.d.ts +0 -1
  2. package/dist/agents/rows.d.ts.map +1 -1
  3. package/dist/agents/runs.d.ts.map +1 -1
  4. package/dist/index.d.ts +4 -0
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +7596 -6416
  7. package/dist/index.js.map +25 -19
  8. package/dist/migrations.d.ts.map +1 -1
  9. package/dist/objects/index.d.ts +2 -0
  10. package/dist/objects/index.d.ts.map +1 -0
  11. package/dist/{pg-object-query-compiler.d.ts → objects/query-compiler.d.ts} +25 -4
  12. package/dist/objects/query-compiler.d.ts.map +1 -0
  13. package/dist/objects/read-scope.d.ts +17 -0
  14. package/dist/objects/read-scope.d.ts.map +1 -0
  15. package/dist/objects/reader.d.ts +50 -0
  16. package/dist/objects/reader.d.ts.map +1 -0
  17. package/dist/objects/rows.d.ts +47 -0
  18. package/dist/objects/rows.d.ts.map +1 -0
  19. package/dist/objects/selected-reader.d.ts +8 -0
  20. package/dist/objects/selected-reader.d.ts.map +1 -0
  21. package/dist/{pg-object-storage.d.ts → objects/storage.d.ts} +12 -20
  22. package/dist/objects/storage.d.ts.map +1 -0
  23. package/dist/pg-action-run-storage.d.ts.map +1 -1
  24. package/dist/pg-ai-cost-storage.d.ts.map +1 -1
  25. package/dist/pg-ai-usage-storage.d.ts.map +1 -1
  26. package/dist/pg-execution-storage.d.ts +3 -2
  27. package/dist/pg-execution-storage.d.ts.map +1 -1
  28. package/dist/pg-pipeline-run-storage.d.ts.map +1 -1
  29. package/dist/pg-sync-run-storage.d.ts.map +1 -1
  30. package/dist/pg-workflow-run-storage.d.ts.map +1 -1
  31. package/dist/share-grant-storage.d.ts +11 -0
  32. package/dist/share-grant-storage.d.ts.map +1 -0
  33. package/dist/share-session-storage.d.ts +11 -0
  34. package/dist/share-session-storage.d.ts.map +1 -0
  35. package/dist/storage-errors.d.ts +2 -0
  36. package/dist/storage-errors.d.ts.map +1 -1
  37. package/dist/transactions.d.ts +9 -2
  38. package/dist/transactions.d.ts.map +1 -1
  39. package/package.json +4 -4
  40. package/src/agents/rows.ts +0 -5
  41. package/src/agents/runs.ts +1 -6
  42. package/src/ai-cost-groups.ts +3 -0
  43. package/src/index.ts +20 -5
  44. package/src/migrations/035-share-grants.sql +57 -0
  45. package/src/migrations/036-share-sessions.sql +173 -0
  46. package/src/migrations/037-execution-requester-groups.sql +21 -0
  47. package/src/migrations.ts +9 -0
  48. package/src/objects/index.ts +1 -0
  49. package/src/{pg-object-query-compiler.ts → objects/query-compiler.ts} +185 -100
  50. package/src/objects/read-scope.ts +324 -0
  51. package/src/objects/reader.ts +604 -0
  52. package/src/objects/rows.ts +146 -0
  53. package/src/objects/selected-reader.ts +118 -0
  54. package/src/objects/storage.ts +305 -0
  55. package/src/pg-action-run-storage.ts +6 -7
  56. package/src/pg-ai-cost-storage.ts +11 -2
  57. package/src/pg-ai-usage-storage.ts +1 -2
  58. package/src/pg-execution-storage.ts +27 -6
  59. package/src/pg-pipeline-run-storage.ts +2 -5
  60. package/src/pg-sync-run-storage.ts +2 -5
  61. package/src/pg-workflow-run-storage.ts +0 -5
  62. package/src/share-grant-storage.ts +236 -0
  63. package/src/share-session-storage.ts +127 -0
  64. package/src/storage-errors.ts +6 -0
  65. package/src/transactions.ts +46 -5
  66. package/dist/pg-object-query-compiler.d.ts.map +0 -1
  67. package/dist/pg-object-storage.d.ts.map +0 -1
  68. package/src/pg-object-storage.ts +0 -734
@@ -0,0 +1,146 @@
1
+ import type {
2
+ ExpandedLinkValue,
3
+ ExpandedObjectRow,
4
+ ObjectLinkRow,
5
+ ObjectRow,
6
+ ObjectRowLinks,
7
+ } from "@sixb/core/storage"
8
+
9
+ export function rowToObject(row: ObjectDatabaseRow): ObjectRow {
10
+ return {
11
+ projectId: row.project_id,
12
+ objectTypeId: row.object_type_id,
13
+ primaryId: row.primary_id,
14
+ properties: row.properties as Record<string, unknown>,
15
+ createdAt: new Date(row.created_at),
16
+ updatedAt: new Date(row.updated_at),
17
+ version: row.version,
18
+ lastCommitId: row.last_commit_id,
19
+ }
20
+ }
21
+
22
+ // Map a query row, attaching `links` when an `expand` pushdown produced them. The
23
+ // base columns map as usual; `_expand` (a `jsonb_build_object`) is revived into
24
+ // the runtime link shape the core executor's fallback also produces.
25
+ export function queryRowToObject(row: ObjectQueryDatabaseRow): ObjectRow {
26
+ const base = rowToObject(row)
27
+ const links = reviveExpandedLinks(row._expand)
28
+ return links ? { ...base, links } : base
29
+ }
30
+
31
+ function reviveExpandedLinks(value: unknown): ObjectRowLinks | undefined {
32
+ if (!isPlainRecord(value)) return undefined
33
+ const links: ObjectRowLinks = {}
34
+ for (const [linkId, raw] of Object.entries(value)) {
35
+ links[linkId] = reviveExpandedLinkValue(raw)
36
+ }
37
+ return links
38
+ }
39
+
40
+ // A "one" expansion arrives as a single object or null; a "many" expansion as an
41
+ // array (already ordered and trimmed in the database).
42
+ function reviveExpandedLinkValue(value: unknown): ExpandedLinkValue {
43
+ if (value === null || value === undefined) return null
44
+ if (Array.isArray(value)) return value.map(reviveExpandedRow)
45
+ return reviveExpandedRow(value)
46
+ }
47
+
48
+ // Revive one hydrated neighbour from `compileExpansionChildJson`: JSONB timestamp
49
+ // strings back to `Date`, empty link properties dropped (matching the fallback),
50
+ // and nested links recursed.
51
+ function reviveExpandedRow(value: unknown): ExpandedObjectRow {
52
+ const row = isPlainRecord(value) ? value : {}
53
+ const expanded: ExpandedObjectRow = {
54
+ projectId: String(row.projectId),
55
+ objectTypeId: String(row.objectTypeId),
56
+ primaryId: String(row.primaryId),
57
+ properties: isPlainRecord(row.properties) ? row.properties : {},
58
+ createdAt: new Date(row.createdAt as string),
59
+ updatedAt: new Date(row.updatedAt as string),
60
+ version: Number(row.version),
61
+ lastCommitId: String(row.lastCommitId),
62
+ }
63
+ if (isPlainRecord(row.linkProperties) && Object.keys(row.linkProperties).length > 0) {
64
+ expanded.linkProperties = row.linkProperties
65
+ }
66
+ const links = reviveExpandedLinks(row.links)
67
+ if (links) expanded.links = links
68
+ return expanded
69
+ }
70
+
71
+ function isPlainRecord(value: unknown): value is Record<string, unknown> {
72
+ return typeof value === "object" && value !== null && !Array.isArray(value)
73
+ }
74
+
75
+ export function rowToLink(row: LinkDatabaseRow): ObjectLinkRow {
76
+ return {
77
+ projectId: row.project_id,
78
+ sourceTypeId: row.source_type_id,
79
+ sourceId: row.source_id,
80
+ linkId: row.link_id,
81
+ targetTypeId: row.target_type_id,
82
+ targetId: row.target_id,
83
+ ...(row.properties === null ? {} : { properties: row.properties as Record<string, unknown> }),
84
+ createdAt: new Date(row.created_at),
85
+ updatedAt: new Date(row.updated_at),
86
+ lastCommitId: row.last_commit_id,
87
+ }
88
+ }
89
+
90
+ export function linkIdentity(link: ObjectLinkRow): string {
91
+ return JSON.stringify([
92
+ link.sourceTypeId,
93
+ link.sourceId,
94
+ link.linkId,
95
+ link.targetTypeId,
96
+ link.targetId,
97
+ ])
98
+ }
99
+
100
+ export interface ObjectDatabaseRow {
101
+ project_id: string
102
+ object_type_id: string
103
+ primary_id: string
104
+ properties: unknown
105
+ created_at: Date | string
106
+ updated_at: Date | string
107
+ version: number
108
+ last_commit_id: string
109
+ }
110
+
111
+ export interface ObjectQueryDatabaseRow extends ObjectDatabaseRow {
112
+ _cursor_properties?: unknown
113
+ /** `jsonb_build_object(linkId, value, ...)` from an `expand` pushdown; absent otherwise. */
114
+ _expand?: unknown
115
+ }
116
+
117
+ export interface ObjectBatchDatabaseRow extends ObjectDatabaseRow {
118
+ _batch_index: number
119
+ }
120
+
121
+ export interface PropertyPermissionBatchDatabaseRow {
122
+ _batch_index: number
123
+ }
124
+
125
+ export interface FacetDatabaseRow {
126
+ value_type: string | null
127
+ value_text: string | null
128
+ count: string | number | bigint
129
+ }
130
+
131
+ export interface LinkDatabaseRow {
132
+ project_id: string
133
+ source_type_id: string
134
+ source_id: string
135
+ link_id: string
136
+ target_type_id: string
137
+ target_id: string
138
+ properties: unknown | null
139
+ created_at: Date | string
140
+ updated_at: Date | string
141
+ last_commit_id: string
142
+ }
143
+
144
+ export interface LinkBatchDatabaseRow extends LinkDatabaseRow {
145
+ _batch_index: number
146
+ }
@@ -0,0 +1,118 @@
1
+ import type {
2
+ CompiledSelectedObjectReadScope,
3
+ ObjectQueryCapabilities,
4
+ ObjectReadExecutionLimits,
5
+ ObjectReadStorage,
6
+ } from "@sixb/core/storage"
7
+ import {
8
+ assertObjectReaderProject,
9
+ assertObjectReadFacetCount,
10
+ assertObjectReadOutputWithinLimit,
11
+ ObjectReadLimitExceededError,
12
+ snapshotObjectReadExecutionLimits,
13
+ } from "@sixb/core/storage"
14
+ import type { SQLClient, SqlParameter } from "../pg-client"
15
+ import { type PgStoreClient, runPgRepeatableReadTransaction } from "../transactions"
16
+ import { compilePgSelectedObjectReadSource, type PgSelectedObjectReadSource } from "./read-scope"
17
+
18
+ import { assertLinkQueryLimit, PgObjectReader } from "./reader"
19
+
20
+ export function createPgSelectedReader(
21
+ sql: PgStoreClient,
22
+ params: {
23
+ projectId: string
24
+ scope: CompiledSelectedObjectReadScope
25
+ limits: ObjectReadExecutionLimits
26
+ },
27
+ queryCapabilities: () => ObjectQueryCapabilities
28
+ ): ObjectReadStorage {
29
+ const projectId = params.projectId
30
+ const limits = snapshotObjectReadExecutionLimits(params.limits)
31
+ const source = compilePgSelectedObjectReadSource(
32
+ projectId,
33
+ params.scope,
34
+ limits.maxTraversalFacts
35
+ )
36
+ const assertProject = (actualProjectId: string): void =>
37
+ assertObjectReaderProject(projectId, actualProjectId)
38
+ const read = <T>(run: (reader: PgObjectReader) => Promise<T>): Promise<T> =>
39
+ runPgRepeatableReadTransaction(sql, async (tx) => {
40
+ await assertTraversalBudget(tx, source, limits.maxTraversalFacts)
41
+ const value = await run(new PgObjectReader(tx, source))
42
+ assertObjectReadOutputWithinLimit(value, limits)
43
+ return value
44
+ })
45
+ const readMap = <TKey, TValue>(
46
+ run: (reader: PgObjectReader) => Promise<Map<TKey, TValue>>
47
+ ): Promise<Map<TKey, TValue>> =>
48
+ runPgRepeatableReadTransaction(sql, async (tx) => {
49
+ await assertTraversalBudget(tx, source, limits.maxTraversalFacts)
50
+ const value = await run(new PgObjectReader(tx, source))
51
+ assertObjectReadOutputWithinLimit([...value.entries()], limits)
52
+ return value
53
+ })
54
+
55
+ return Object.freeze({
56
+ queryCapabilities,
57
+ queryObjects: async (input) => {
58
+ assertProject(input.projectId)
59
+ return read((reader) => reader.queryObjects(input))
60
+ },
61
+ countObjects: async (input) => {
62
+ assertProject(input.projectId)
63
+ return read((reader) => reader.countObjects(input))
64
+ },
65
+ existsObjects: async (input) => {
66
+ assertProject(input.projectId)
67
+ return read((reader) => reader.existsObjects(input))
68
+ },
69
+ facetObjects: async (input) => {
70
+ assertProject(input.projectId)
71
+ assertObjectReadFacetCount(input.facets.length)
72
+ return read((reader) => reader.facetObjects(input))
73
+ },
74
+ getByPrimaryId: async (input) => {
75
+ assertProject(input.projectId)
76
+ return read((reader) => reader.getByPrimaryId(input))
77
+ },
78
+ selectsObjectProperties: async (input) => {
79
+ assertProject(input.projectId)
80
+ return read((reader) => reader.selectsObjectProperties(input))
81
+ },
82
+ listLinks: async (input) => {
83
+ assertProject(input.projectId)
84
+ return read((reader) => reader.listLinks(input))
85
+ },
86
+ getByPrimaryIdBatch: async (input) => {
87
+ assertProject(input.projectId)
88
+ return readMap((reader) => reader.getByPrimaryIdBatch(input))
89
+ },
90
+ listLinksBatch: async (input) => {
91
+ assertProject(input.projectId)
92
+ return readMap((reader) => reader.listLinksBatch(input))
93
+ },
94
+ queryLinks: async (input) => {
95
+ assertProject(input.projectId)
96
+ assertLinkQueryLimit(input.limit)
97
+ return read((reader) => reader.queryLinks(input))
98
+ },
99
+ list: async (input) => {
100
+ assertProject(input.projectId)
101
+ return read((reader) => reader.list(input))
102
+ },
103
+ } satisfies ObjectReadStorage)
104
+ }
105
+
106
+ async function assertTraversalBudget(
107
+ sql: SQLClient,
108
+ source: PgSelectedObjectReadSource,
109
+ maxTraversalFacts: number
110
+ ): Promise<void> {
111
+ const [row] = await sql.unsafe<{ total: string | number | bigint }[]>(
112
+ source.traversalProbe.sql,
113
+ source.traversalProbe.args as SqlParameter[]
114
+ )
115
+ if (BigInt(row?.total ?? 0) > BigInt(maxTraversalFacts)) {
116
+ throw new ObjectReadLimitExceededError("traversalFacts", maxTraversalFacts)
117
+ }
118
+ }
@@ -0,0 +1,305 @@
1
+ import type {
2
+ CompiledSelectedObjectReadScope,
3
+ CountObjectsInput,
4
+ CountObjectsResult,
5
+ ExistsObjectsInput,
6
+ ExistsObjectsResult,
7
+ FacetObjectsInput,
8
+ FacetObjectsResult,
9
+ LinkBatchKey,
10
+ LinkDirection,
11
+ ObjectBatchKey,
12
+ ObjectLinkRow,
13
+ ObjectQueryCapabilities,
14
+ ObjectReadExecutionLimits,
15
+ ObjectReadStorage,
16
+ ObjectRow,
17
+ ObjectStorage,
18
+ QueryObjectLinksInput,
19
+ QueryObjectLinksResult,
20
+ QueryObjectsInput,
21
+ QueryObjectsResult,
22
+ } from "@sixb/core/storage"
23
+ import type { SQLClient, SqlParameter } from "../pg-client"
24
+ import type { PgStoreClient } from "../transactions"
25
+ import { DEFAULT_OBJECT_QUERY_SOURCE } from "./query-compiler"
26
+ import { assertLinkQueryLimit, PgObjectReader } from "./reader"
27
+ import {
28
+ type LinkDatabaseRow,
29
+ linkIdentity,
30
+ type ObjectDatabaseRow,
31
+ rowToLink,
32
+ rowToObject,
33
+ } from "./rows"
34
+ import { createPgSelectedReader } from "./selected-reader"
35
+
36
+ const PG_OBJECT_QUERY_CAPABILITIES: ObjectQueryCapabilities = {
37
+ queryObjects: true,
38
+ countObjects: true,
39
+ existsObjects: true,
40
+ facetObjects: true,
41
+ nodes: {
42
+ start: true,
43
+ refs: true,
44
+ filter: true,
45
+ text: true,
46
+ sort: true,
47
+ limit: true,
48
+ page: true,
49
+ traverse: true,
50
+ set: true,
51
+ project: true,
52
+ expand: true,
53
+ },
54
+ predicateOps: {
55
+ and: true,
56
+ or: true,
57
+ not: true,
58
+ eq: true,
59
+ neq: true,
60
+ lt: true,
61
+ lte: true,
62
+ gt: true,
63
+ gte: true,
64
+ in: true,
65
+ exists: true,
66
+ contains: true,
67
+ },
68
+ sortKinds: {
69
+ property: true,
70
+ },
71
+ traversalDirections: {
72
+ outgoing: true,
73
+ incoming: true,
74
+ },
75
+ setOps: {
76
+ union: true,
77
+ intersect: true,
78
+ subtract: true,
79
+ },
80
+ scalarOperations: {
81
+ string: { equality: true, ordering: true },
82
+ uuid: { equality: true, ordering: true },
83
+ boolean: { equality: true },
84
+ integer: { equality: true, ordering: true },
85
+ double: { equality: true, ordering: true },
86
+ decimal: { equality: true, ordering: true },
87
+ date: { equality: true, ordering: true },
88
+ timestamp: { equality: true, ordering: true },
89
+ },
90
+ limits: {
91
+ totalCount: true,
92
+ stablePageTokens: true,
93
+ },
94
+ notes: [
95
+ "PostgreSQL object query pushdown supports start/refs/filter/text/sort/limit/page/traverse/set/project/expand over JSONB properties and object links.",
96
+ "expand hydrates linked objects in-database (top-N per parent via LATERAL + jsonb_agg); core resolves each expansion's cardinality before pushdown, and a mixed/unresolved one stays on the fallback.",
97
+ "Exact decimal predicates, ordering, and keyset pagination use PostgreSQL numeric casts.",
98
+ "Relevance sorting, vector search, and unresolved start.includeSubtypes remain planner fallback or rejection cases.",
99
+ ],
100
+ }
101
+
102
+ /** PostgreSQL object provider; interactive reads share one source-aware reader. */
103
+ export class PgObjectStorage implements ObjectStorage {
104
+ private readonly reader: PgObjectReader
105
+
106
+ constructor(private readonly sql: PgStoreClient) {
107
+ this.reader = new PgObjectReader(sql, DEFAULT_OBJECT_QUERY_SOURCE)
108
+ }
109
+
110
+ queryCapabilities(): ObjectQueryCapabilities {
111
+ return PG_OBJECT_QUERY_CAPABILITIES
112
+ }
113
+
114
+ createSelectedReadScope(params: {
115
+ projectId: string
116
+ scope: CompiledSelectedObjectReadScope
117
+ limits: ObjectReadExecutionLimits
118
+ }): ObjectReadStorage {
119
+ return createPgSelectedReader(this.sql, params, () => this.queryCapabilities())
120
+ }
121
+
122
+ async queryObjects(params: QueryObjectsInput): Promise<QueryObjectsResult> {
123
+ return this.reader.queryObjects(params)
124
+ }
125
+
126
+ async countObjects(params: CountObjectsInput): Promise<CountObjectsResult> {
127
+ return this.reader.countObjects(params)
128
+ }
129
+
130
+ async existsObjects(params: ExistsObjectsInput): Promise<ExistsObjectsResult> {
131
+ return this.reader.existsObjects(params)
132
+ }
133
+
134
+ async facetObjects(params: FacetObjectsInput): Promise<FacetObjectsResult> {
135
+ return this.reader.facetObjects(params)
136
+ }
137
+
138
+ async getByPrimaryId(params: {
139
+ projectId: string
140
+ objectTypeId: string
141
+ primaryId: string
142
+ }): Promise<ObjectRow | null> {
143
+ return this.reader.getByPrimaryId(params)
144
+ }
145
+
146
+ async selectsObjectProperties(
147
+ params: Parameters<ObjectReadStorage["selectsObjectProperties"]>[0]
148
+ ): Promise<readonly boolean[]> {
149
+ return this.reader.selectsObjectProperties(params)
150
+ }
151
+
152
+ async listLinks(params: {
153
+ projectId: string
154
+ objectTypeId: string
155
+ objectId: string
156
+ linkId?: string
157
+ direction?: LinkDirection
158
+ }): Promise<readonly ObjectLinkRow[]> {
159
+ return this.reader.listLinks(params)
160
+ }
161
+
162
+ async getByPrimaryIdBatch(params: {
163
+ projectId: string
164
+ items: readonly { objectTypeId: string; primaryId: string }[]
165
+ }): Promise<Map<ObjectBatchKey, ObjectRow>> {
166
+ return this.reader.getByPrimaryIdBatch(params)
167
+ }
168
+
169
+ async listLinksBatch(params: {
170
+ projectId: string
171
+ direction?: LinkDirection
172
+ items: readonly { objectTypeId: string; objectId: string; linkId: string }[]
173
+ }): Promise<Map<LinkBatchKey, ObjectLinkRow[]>> {
174
+ return this.reader.listLinksBatch(params)
175
+ }
176
+
177
+ async queryLinks(params: QueryObjectLinksInput): Promise<QueryObjectLinksResult> {
178
+ assertLinkQueryLimit(params.limit)
179
+ return this.reader.queryLinks(params)
180
+ }
181
+
182
+ async listIncidentLinksBatch(params: {
183
+ projectId: string
184
+ items: readonly { objectTypeId: string; objectId: string }[]
185
+ }): Promise<readonly ObjectLinkRow[]> {
186
+ if (params.items.length === 0) return []
187
+ const tuples = params.items.map((item) => [item.objectTypeId, item.objectId])
188
+
189
+ // Cover both link directions with two index-friendly equality joins (source-side, then
190
+ // target-side) rather than a single OR-join, which would defeat index usage. This is a constant
191
+ // number of round trips regardless of the requested object count. They are issued sequentially
192
+ // because callers may run on a serializable transaction's single connection. A link incident to
193
+ // two listed objects matches both halves and is de-duplicated below.
194
+ const sourceRows = await valuesJoin<LinkDatabaseRow>(
195
+ this.sql,
196
+ "SELECT l.* FROM links l",
197
+ ["source_type_id", "source_id"],
198
+ tuples,
199
+ "WHERE l.project_id = $1",
200
+ [params.projectId]
201
+ )
202
+ const targetRows = await valuesJoin<LinkDatabaseRow>(
203
+ this.sql,
204
+ "SELECT l.* FROM links l",
205
+ ["target_type_id", "target_id"],
206
+ tuples,
207
+ "WHERE l.project_id = $1",
208
+ [params.projectId]
209
+ )
210
+
211
+ const deduped = new Map<string, ObjectLinkRow>()
212
+ for (const row of [...sourceRows, ...targetRows]) {
213
+ const link = rowToLink(row)
214
+ deduped.set(linkIdentity(link), link)
215
+ }
216
+ return [...deduped.values()]
217
+ }
218
+
219
+ async listByPrimaryIdPage(params: {
220
+ projectId: string
221
+ objectTypeId: string
222
+ afterPrimaryId?: string
223
+ limit: number
224
+ }): Promise<{ objects: readonly ObjectRow[]; nextPrimaryId?: string }> {
225
+ assertReconciliationPageLimit(params.limit)
226
+ const rows = await this.sql<ObjectDatabaseRow[]>`
227
+ SELECT * FROM objects
228
+ WHERE project_id = ${params.projectId}
229
+ AND object_type_id = ${params.objectTypeId}
230
+ ${params.afterPrimaryId ? this.sql`AND primary_id > ${params.afterPrimaryId}` : this.sql``}
231
+ ORDER BY primary_id ASC
232
+ LIMIT ${params.limit + 1}
233
+ `
234
+ const hasMore = rows.length > params.limit
235
+ const objects = rows.slice(0, params.limit).map((row) => rowToObject(row))
236
+ const last = objects.at(-1)
237
+ return {
238
+ objects,
239
+ ...(hasMore && last ? { nextPrimaryId: last.primaryId } : {}),
240
+ }
241
+ }
242
+
243
+ async list(params: {
244
+ projectId: string
245
+ objectTypeId?: string | readonly string[]
246
+ primaryIdPrefix?: string
247
+ primaryIdSuffix?: string
248
+ updatedAfter?: Date
249
+ updatedBefore?: Date
250
+ createdAfter?: Date
251
+ createdBefore?: Date
252
+ limit?: number
253
+ offset?: number
254
+ orderBy?: "createdAt" | "updatedAt" | "primaryId"
255
+ order?: "asc" | "desc"
256
+ }): Promise<{ objects: readonly ObjectRow[]; hasMore: boolean; total: number }> {
257
+ return this.reader.list(params)
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Build a `SELECT ... JOIN (VALUES ...) AS t(...) ON ... WHERE ...` query
263
+ * with positional parameters. Bun SQL's tagged-template helpers don't
264
+ * support VALUES inside SELECT/JOIN, so we construct the query string
265
+ * manually while keeping all user data in the parameter array.
266
+ */
267
+ function valuesJoin<Row = unknown>(
268
+ sql: SQLClient,
269
+ select: string,
270
+ columns: string[],
271
+ tuples: unknown[][],
272
+ where: string,
273
+ whereParams: unknown[]
274
+ ): Promise<Row[]> {
275
+ const alias = "t"
276
+ const colWidth = columns.length
277
+
278
+ // $1 … $N are reserved for the WHERE params that come first.
279
+ const base = whereParams.length
280
+ const valuePlaceholders = tuples
281
+ .map((_, i) => {
282
+ const cols = columns.map((_, j) => `$${base + i * colWidth + j + 1}`)
283
+ return `(${cols.join(",")})`
284
+ })
285
+ .join(",")
286
+
287
+ const onClause = columns
288
+ .map((c) => {
289
+ // Infer the table alias from the SELECT clause (first word after SELECT ... FROM)
290
+ const srcAlias = select.match(/FROM\s+\w+\s+(\w+)/i)?.[1] ?? select.split(" ").pop()
291
+ return `${srcAlias}.${c} = ${alias}.${c}`
292
+ })
293
+ .join(" AND ")
294
+
295
+ const query = `${select} JOIN (VALUES ${valuePlaceholders}) AS ${alias}(${columns.join(",")}) ON ${onClause} ${where}`
296
+ const params = [...whereParams, ...tuples.flat()]
297
+
298
+ return sql.unsafe(query, params as SqlParameter[]) as unknown as Promise<Row[]>
299
+ }
300
+
301
+ function assertReconciliationPageLimit(limit: number): void {
302
+ if (!Number.isSafeInteger(limit) || limit <= 0) {
303
+ throw new Error("Object reconciliation page limit must be a positive safe integer.")
304
+ }
305
+ }
@@ -4,6 +4,11 @@ import {
4
4
  serializeActionRunFailure,
5
5
  } from "@sixb/core/internal/action-run-storage"
6
6
  import { assertActionRunExecution } from "@sixb/core/internal/action-run-storage-provider"
7
+ import {
8
+ actionRunPhaseRecordsEqual,
9
+ canRequeueActionRunAfterEnqueueFailure,
10
+ finishActionRunPhase,
11
+ } from "@sixb/core/internal/storage"
7
12
  import type {
8
13
  ActionRunEffectsRecord,
9
14
  ActionRunFailure,
@@ -23,13 +28,7 @@ import type {
23
28
  RecordActionWritebackInput,
24
29
  StartActionRunInput,
25
30
  } from "@sixb/core/storage"
26
- import {
27
- ActionRunError,
28
- actionRunPhaseRecordsEqual,
29
- canRequeueActionRunAfterEnqueueFailure,
30
- finishActionRunPhase,
31
- isTerminalActionRun,
32
- } from "@sixb/core/storage"
31
+ import { ActionRunError, isTerminalActionRun } from "@sixb/core/storage"
33
32
  import type { SQLClient, SqlParameter } from "./pg-client"
34
33
  import { isUniqueViolation } from "./storage-errors"
35
34
  import { type PgStoreClient, runPgTransaction } from "./transactions"
@@ -4,6 +4,7 @@ import {
4
4
  aiModelCallCostDetails,
5
5
  aiModelCallCostMatchesUsage,
6
6
  buildAiAccountingOverviewFromFragments,
7
+ directModelCallAttribution,
7
8
  normalizeAiAccountingQuery,
8
9
  normalizeAiModelCallAccountingQuery,
9
10
  normalizeAiModelCallCostRecord,
@@ -117,11 +118,14 @@ export class PgAiCostStorage implements AiCostStorage {
117
118
  ELSE NULL
118
119
  END AS attribution_agent_kind,
119
120
  workflow_node.node_id AS attribution_agent_step_id,
120
- workflow_node.workflow_id AS attribution_workflow_id,
121
+ COALESCE(workflow_node.workflow_id,
122
+ CASE WHEN execution.executor_kind = 'workflow' THEN execution.authority_primitive_id END
123
+ ) AS attribution_workflow_id,
121
124
  cost.status AS cost_status,
122
125
  cost.currency AS cost_currency,
123
126
  cost.amount_nanos AS cost_amount_nanos
124
127
  FROM ai_model_call_usage AS usage
128
+ JOIN executions AS execution ON execution.project_id = usage.project_id AND execution.id = usage.execution_id
125
129
  LEFT JOIN agent_runs AS direct_agent
126
130
  ON direct_agent.project_id = usage.project_id
127
131
  AND direct_agent.execution_id = usage.execution_id
@@ -251,6 +255,7 @@ export class PgAiCostStorage implements AiCostStorage {
251
255
  workflow_agent.node_run_id AS attribution_node_run_id,
252
256
  workflow_node.workflow_id AS attribution_workflow_id,
253
257
  workflow_node.workflow_run_id AS attribution_workflow_run_id,
258
+ execution.executor_kind, execution.executor_id, execution.authority_primitive_id AS primitive_id,
254
259
  COALESCE((
255
260
  SELECT jsonb_agg(groups.group_id ORDER BY groups.group_id)
256
261
  FROM ai_model_call_usage_groups AS groups
@@ -265,6 +270,7 @@ export class PgAiCostStorage implements AiCostStorage {
265
270
  cost.details AS cost_details,
266
271
  cost.rated_at AS cost_rated_at
267
272
  FROM ai_model_call_usage AS usage
273
+ JOIN executions AS execution ON execution.project_id = usage.project_id AND execution.id = usage.execution_id
268
274
  LEFT JOIN agent_runs AS direct_agent
269
275
  ON direct_agent.project_id = usage.project_id
270
276
  AND direct_agent.execution_id = usage.execution_id
@@ -342,6 +348,9 @@ interface UsageRow {
342
348
  }
343
349
 
344
350
  interface JoinedRow extends UsageRow {
351
+ readonly executor_kind: string | null
352
+ readonly executor_id: string | null
353
+ readonly primitive_id: string | null
345
354
  readonly attribution_kind: "agent" | "subagent" | "workflowAgent" | null
346
355
  readonly attribution_agent_step_id: string | null
347
356
  readonly attribution_agent_run_id: string | null
@@ -491,7 +500,7 @@ function accountingItemFromRow(row: JoinedRow): AiAccountingRecordSetItem {
491
500
  const cost = row.cost_status === null ? undefined : costFromJoinedRow(row)
492
501
  const attribution =
493
502
  row.attribution_kind === null
494
- ? undefined
503
+ ? directModelCallAttribution(row.executor_kind, row.executor_id, row.primitive_id)
495
504
  : row.attribution_kind === "agent"
496
505
  ? {
497
506
  kind: "agent" as const,
@@ -1,3 +1,4 @@
1
+ import { assertAiUsageExecutionId, normalizeAiModelCallRecord } from "@sixb/core/internal/storage"
1
2
  import { isModelReasoning, normalizeModelProviderIds } from "@sixb/core/models"
2
3
  import type { ReadonlyJsonObject } from "@sixb/core/storage"
3
4
  import {
@@ -7,9 +8,7 @@ import {
7
8
  type AiUsageStorage,
8
9
  AiUsageStorageError,
9
10
  aggregateAiModelCallUsage,
10
- assertAiUsageExecutionId,
11
11
  type GetLatestAiModelCallForExecutionInput,
12
- normalizeAiModelCallRecord,
13
12
  type RecordAiModelCallInput,
14
13
  type RecordAiModelCallResult,
15
14
  type SummarizeAiUsageExecutionInput,