@sixb/pg 0.1.7 → 0.1.8
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/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7548 -6387
- package/dist/index.js.map +16 -10
- package/dist/migrations.d.ts.map +1 -1
- package/dist/objects/index.d.ts +2 -0
- package/dist/objects/index.d.ts.map +1 -0
- package/dist/{pg-object-query-compiler.d.ts → objects/query-compiler.d.ts} +25 -4
- package/dist/objects/query-compiler.d.ts.map +1 -0
- package/dist/objects/read-scope.d.ts +17 -0
- package/dist/objects/read-scope.d.ts.map +1 -0
- package/dist/objects/reader.d.ts +50 -0
- package/dist/objects/reader.d.ts.map +1 -0
- package/dist/objects/rows.d.ts +47 -0
- package/dist/objects/rows.d.ts.map +1 -0
- package/dist/objects/selected-reader.d.ts +8 -0
- package/dist/objects/selected-reader.d.ts.map +1 -0
- package/dist/{pg-object-storage.d.ts → objects/storage.d.ts} +12 -20
- package/dist/objects/storage.d.ts.map +1 -0
- package/dist/pg-execution-storage.d.ts +3 -2
- package/dist/pg-execution-storage.d.ts.map +1 -1
- package/dist/share-grant-storage.d.ts +11 -0
- package/dist/share-grant-storage.d.ts.map +1 -0
- package/dist/share-session-storage.d.ts +11 -0
- package/dist/share-session-storage.d.ts.map +1 -0
- package/dist/storage-errors.d.ts +2 -0
- package/dist/storage-errors.d.ts.map +1 -1
- package/dist/transactions.d.ts +9 -2
- package/dist/transactions.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/index.ts +16 -3
- package/src/migrations/035-share-grants.sql +57 -0
- package/src/migrations/036-share-sessions.sql +173 -0
- package/src/migrations.ts +4 -0
- package/src/objects/index.ts +1 -0
- package/src/{pg-object-query-compiler.ts → objects/query-compiler.ts} +185 -100
- package/src/objects/read-scope.ts +324 -0
- package/src/objects/reader.ts +604 -0
- package/src/objects/rows.ts +146 -0
- package/src/objects/selected-reader.ts +118 -0
- package/src/objects/storage.ts +305 -0
- package/src/pg-execution-storage.ts +21 -2
- package/src/share-grant-storage.ts +236 -0
- package/src/share-session-storage.ts +127 -0
- package/src/storage-errors.ts +6 -0
- package/src/transactions.ts +46 -5
- package/dist/pg-object-query-compiler.d.ts.map +0 -1
- package/dist/pg-object-storage.d.ts.map +0 -1
- 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
|
+
}
|
|
@@ -5,7 +5,12 @@ import {
|
|
|
5
5
|
normalizeExecutionRecord,
|
|
6
6
|
validateExecutionRecordReferences,
|
|
7
7
|
} from "@sixb/core/internal/execution-storage"
|
|
8
|
-
import type {
|
|
8
|
+
import type {
|
|
9
|
+
CreateExecutionInput,
|
|
10
|
+
ExecutionRecord,
|
|
11
|
+
ExecutionStorage,
|
|
12
|
+
ShareSessionStorage,
|
|
13
|
+
} from "@sixb/core/storage"
|
|
9
14
|
import { ExecutionStorageError } from "@sixb/core/storage"
|
|
10
15
|
import type { PgAuthStorage } from "./auth-storage"
|
|
11
16
|
import { isUniqueViolation } from "./storage-errors"
|
|
@@ -14,7 +19,8 @@ import type { PgStoreClient } from "./transactions"
|
|
|
14
19
|
export class PgExecutionStorage implements ExecutionStorage {
|
|
15
20
|
constructor(
|
|
16
21
|
private readonly sql: PgStoreClient,
|
|
17
|
-
private readonly auth: PgAuthStorage
|
|
22
|
+
private readonly auth: PgAuthStorage,
|
|
23
|
+
private readonly shareSessions: Pick<ShareSessionStorage, "getById">
|
|
18
24
|
) {}
|
|
19
25
|
|
|
20
26
|
async create(input: CreateExecutionInput): Promise<ExecutionRecord> {
|
|
@@ -22,6 +28,7 @@ export class PgExecutionStorage implements ExecutionStorage {
|
|
|
22
28
|
await validateExecutionRecordReferences(record, {
|
|
23
29
|
auth: this.auth,
|
|
24
30
|
getExecution: (params) => this.getById(params),
|
|
31
|
+
getShareSession: (params) => this.shareSessions.getById(params),
|
|
25
32
|
})
|
|
26
33
|
const row = executionRecordToStorageRow(record)
|
|
27
34
|
|
|
@@ -46,6 +53,9 @@ export class PgExecutionStorage implements ExecutionStorage {
|
|
|
46
53
|
authority_primitive_kind,
|
|
47
54
|
authority_primitive_id,
|
|
48
55
|
authority_kernel_operation,
|
|
56
|
+
authority_delegation_kind,
|
|
57
|
+
authority_delegation_id,
|
|
58
|
+
authority_delegation_session_id,
|
|
49
59
|
created_at
|
|
50
60
|
) VALUES (
|
|
51
61
|
${row.projectId},
|
|
@@ -66,6 +76,9 @@ export class PgExecutionStorage implements ExecutionStorage {
|
|
|
66
76
|
${row.authorityPrimitiveKind},
|
|
67
77
|
${row.authorityPrimitiveId},
|
|
68
78
|
${row.authorityKernelOperation},
|
|
79
|
+
${row.authorityDelegationKind},
|
|
80
|
+
${row.authorityDelegationId},
|
|
81
|
+
${row.authorityDelegationSessionId},
|
|
69
82
|
${row.createdAt}
|
|
70
83
|
)
|
|
71
84
|
RETURNING *
|
|
@@ -120,6 +133,9 @@ function toStorageRow(row: PgExecutionRow): ExecutionStorageRow {
|
|
|
120
133
|
authorityPrimitiveKind: row.authority_primitive_kind,
|
|
121
134
|
authorityPrimitiveId: row.authority_primitive_id,
|
|
122
135
|
authorityKernelOperation: row.authority_kernel_operation,
|
|
136
|
+
authorityDelegationKind: row.authority_delegation_kind,
|
|
137
|
+
authorityDelegationId: row.authority_delegation_id,
|
|
138
|
+
authorityDelegationSessionId: row.authority_delegation_session_id,
|
|
123
139
|
createdAt: new Date(row.created_at),
|
|
124
140
|
}
|
|
125
141
|
}
|
|
@@ -143,5 +159,8 @@ interface PgExecutionRow {
|
|
|
143
159
|
readonly authority_primitive_kind: ExecutionStorageRow["authorityPrimitiveKind"]
|
|
144
160
|
readonly authority_primitive_id: string | null
|
|
145
161
|
readonly authority_kernel_operation: ExecutionStorageRow["authorityKernelOperation"]
|
|
162
|
+
readonly authority_delegation_kind: ExecutionStorageRow["authorityDelegationKind"]
|
|
163
|
+
readonly authority_delegation_id: string | null
|
|
164
|
+
readonly authority_delegation_session_id: string | null
|
|
146
165
|
readonly created_at: Date | string
|
|
147
166
|
}
|