@sixb/sqlite 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/execution-storage.d.ts +3 -2
- package/dist/execution-storage.d.ts.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1492 -380
- package/dist/index.js.map +16 -10
- package/dist/migrations.d.ts.map +1 -1
- package/dist/objects/index.d.ts +3 -0
- package/dist/objects/index.d.ts.map +1 -0
- package/dist/{object-query-compiler.d.ts → objects/query-compiler.d.ts} +16 -1
- 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 +51 -0
- package/dist/objects/reader.d.ts.map +1 -0
- package/dist/objects/rows.d.ts +33 -0
- package/dist/objects/rows.d.ts.map +1 -0
- package/dist/objects/selected-reader.d.ts +9 -0
- package/dist/objects/selected-reader.d.ts.map +1 -0
- package/dist/{object-storage.d.ts → objects/storage.d.ts} +12 -9
- package/dist/objects/storage.d.ts.map +1 -0
- package/dist/share-grant-storage.d.ts +20 -0
- package/dist/share-grant-storage.d.ts.map +1 -0
- package/dist/share-session-storage.d.ts +19 -0
- package/dist/share-session-storage.d.ts.map +1 -0
- package/dist/storage-errors.d.ts +1 -0
- package/dist/storage-errors.d.ts.map +1 -1
- package/dist/transactions.d.ts +8 -0
- package/dist/transactions.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/execution-storage.ts +22 -3
- package/src/index.ts +18 -3
- package/src/migrations/035-share-grants.sql +66 -0
- package/src/migrations/036-share-sessions.sql +226 -0
- package/src/migrations.ts +4 -0
- package/src/objects/index.ts +2 -0
- package/src/{object-query-compiler.ts → objects/query-compiler.ts} +104 -54
- package/src/objects/read-scope.ts +316 -0
- package/src/objects/reader.ts +526 -0
- package/src/objects/rows.ts +123 -0
- package/src/objects/selected-reader.ts +119 -0
- package/src/objects/storage.ts +296 -0
- package/src/share-grant-storage.ts +252 -0
- package/src/share-session-storage.ts +189 -0
- package/src/storage-errors.ts +4 -0
- package/src/transactions.ts +32 -0
- package/dist/object-query-compiler.d.ts.map +0 -1
- package/dist/object-storage.d.ts.map +0 -1
- package/src/object-storage.ts +0 -709
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite"
|
|
2
|
+
import type { ObjectQuery } from "@sixb/core"
|
|
3
|
+
import type {
|
|
4
|
+
CountObjectsInput,
|
|
5
|
+
CountObjectsResult,
|
|
6
|
+
ExistsObjectsInput,
|
|
7
|
+
ExistsObjectsResult,
|
|
8
|
+
FacetObjectsInput,
|
|
9
|
+
FacetObjectsResult,
|
|
10
|
+
LinkBatchKey,
|
|
11
|
+
LinkDirection,
|
|
12
|
+
ObjectBatchKey,
|
|
13
|
+
ObjectFacetRequest,
|
|
14
|
+
ObjectLinkRow,
|
|
15
|
+
ObjectReadStorage,
|
|
16
|
+
ObjectRow,
|
|
17
|
+
QueryObjectLinksInput,
|
|
18
|
+
QueryObjectLinksResult,
|
|
19
|
+
QueryObjectsInput,
|
|
20
|
+
QueryObjectsResult,
|
|
21
|
+
} from "@sixb/core/storage"
|
|
22
|
+
import { linkBatchKey, objectBatchKey } from "@sixb/core/storage"
|
|
23
|
+
import {
|
|
24
|
+
type CompiledObjectQuery,
|
|
25
|
+
compileObjectQuery,
|
|
26
|
+
type SqliteObjectQuerySource,
|
|
27
|
+
} from "./query-compiler"
|
|
28
|
+
import type { SqliteSelectedObjectReadSource } from "./read-scope"
|
|
29
|
+
import {
|
|
30
|
+
type DatabaseRow,
|
|
31
|
+
type LinkDatabaseRow,
|
|
32
|
+
type ObjectQueryDatabaseRow,
|
|
33
|
+
queryRowToObject,
|
|
34
|
+
rowToLink,
|
|
35
|
+
rowToObject,
|
|
36
|
+
} from "./rows"
|
|
37
|
+
|
|
38
|
+
/** Synchronous SQL reads over one explicit source; transaction and budget ownership stay outside. */
|
|
39
|
+
export class SqliteObjectReader {
|
|
40
|
+
constructor(
|
|
41
|
+
private readonly db: Database,
|
|
42
|
+
private readonly source: SqliteObjectQuerySource | SqliteSelectedObjectReadSource
|
|
43
|
+
) {}
|
|
44
|
+
|
|
45
|
+
queryObjects(params: QueryObjectsInput): QueryObjectsResult {
|
|
46
|
+
const source = this.source
|
|
47
|
+
const compiled = compileObjectQuery(params.projectId, params.query, {
|
|
48
|
+
includeTotal: params.includeTotal,
|
|
49
|
+
source,
|
|
50
|
+
})
|
|
51
|
+
const total = params.includeTotal === false ? undefined : readTotal(this.db, compiled)
|
|
52
|
+
const rawRows = this.db.query(compiled.sql).all(...compiled.args) as ObjectQueryDatabaseRow[]
|
|
53
|
+
const rows = compiled.trimRows(rawRows) as readonly ObjectQueryDatabaseRow[]
|
|
54
|
+
const hasMore =
|
|
55
|
+
total === undefined && compiled.hasMoreProbe
|
|
56
|
+
? compiled.hasMoreProbe.hasMore(
|
|
57
|
+
this.db.query(compiled.hasMoreProbe.sql).all(...compiled.hasMoreProbe.args).length
|
|
58
|
+
)
|
|
59
|
+
: compiled.hasMore(rawRows.length, total)
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
objects: rows.map((row) => queryRowToObject(row)),
|
|
63
|
+
hasMore,
|
|
64
|
+
nextPageToken: compiled.nextPageToken(rows, rawRows.length),
|
|
65
|
+
...(total === undefined ? {} : { total }),
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
countObjects(params: CountObjectsInput): CountObjectsResult {
|
|
70
|
+
const source = this.source
|
|
71
|
+
return {
|
|
72
|
+
count: readTotal(
|
|
73
|
+
this.db,
|
|
74
|
+
compileObjectQuery(params.projectId, stripOuterRowShape(params.query), {
|
|
75
|
+
source,
|
|
76
|
+
})
|
|
77
|
+
),
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
existsObjects(params: ExistsObjectsInput): ExistsObjectsResult {
|
|
82
|
+
const source = this.source
|
|
83
|
+
const compiled = compileObjectQuery(params.projectId, existsProbeQuery(params.query), {
|
|
84
|
+
source,
|
|
85
|
+
})
|
|
86
|
+
return { exists: this.db.query(compiled.sql).get(...compiled.args) !== null }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
facetObjects(params: FacetObjectsInput): FacetObjectsResult {
|
|
90
|
+
const source = this.source
|
|
91
|
+
const compiled = compileObjectQuery(params.projectId, stripOuterRowShape(params.query), {
|
|
92
|
+
source,
|
|
93
|
+
})
|
|
94
|
+
return {
|
|
95
|
+
facets: params.facets.map((facet) => ({
|
|
96
|
+
propertyId: facet.propertyId,
|
|
97
|
+
buckets: readFacetBuckets(this.db, compiled, facet),
|
|
98
|
+
})),
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
getByPrimaryId(params: {
|
|
103
|
+
projectId: string
|
|
104
|
+
objectTypeId: string
|
|
105
|
+
primaryId: string
|
|
106
|
+
}): ObjectRow | null {
|
|
107
|
+
const source = this.source
|
|
108
|
+
const objectsTable = source.objectsTable
|
|
109
|
+
const statement = source.wrapStatement(
|
|
110
|
+
`SELECT * FROM ${objectsTable} WHERE project_id = ? AND object_type_id = ? AND primary_id = ?`,
|
|
111
|
+
[params.projectId, params.objectTypeId, params.primaryId]
|
|
112
|
+
)
|
|
113
|
+
const row = this.db.query(statement.sql).get(...statement.args) as DatabaseRow | null
|
|
114
|
+
|
|
115
|
+
return row ? rowToObject(row) : null
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
selectsObjectProperties(
|
|
119
|
+
params: Parameters<ObjectReadStorage["selectsObjectProperties"]>[0]
|
|
120
|
+
): readonly boolean[] {
|
|
121
|
+
const source = this.source
|
|
122
|
+
const result = params.items.map(() => false)
|
|
123
|
+
if (params.items.length === 0) return result
|
|
124
|
+
|
|
125
|
+
const items = params.items.map((item, batchIndex) => ({ ...item, batchIndex }))
|
|
126
|
+
const requestedType = "CAST(json_extract(requested.value, '$.objectTypeId') AS TEXT)"
|
|
127
|
+
const requestedId = "CAST(json_extract(requested.value, '$.primaryId') AS TEXT)"
|
|
128
|
+
const requestedProperty = "CAST(json_extract(requested.value, '$.propertyId') AS TEXT)"
|
|
129
|
+
const selected = "objectPropertyPermissionsTable" in source
|
|
130
|
+
const storedTable = selected ? source.objectPropertyPermissionsTable : source.objectsTable
|
|
131
|
+
const propertyJoin = selected ? `AND stored.property_id = ${requestedProperty}` : ""
|
|
132
|
+
const statement = source.wrapStatement(
|
|
133
|
+
`SELECT DISTINCT
|
|
134
|
+
CAST(json_extract(requested.value, '$.batchIndex') AS INTEGER) AS _batch_index
|
|
135
|
+
FROM ${storedTable} AS stored
|
|
136
|
+
JOIN json_each(?) AS requested
|
|
137
|
+
ON stored.object_type_id = ${requestedType}
|
|
138
|
+
AND stored.primary_id = ${requestedId}
|
|
139
|
+
${propertyJoin}
|
|
140
|
+
WHERE stored.project_id = ?`,
|
|
141
|
+
[JSON.stringify(items), params.projectId]
|
|
142
|
+
)
|
|
143
|
+
const rows = this.db
|
|
144
|
+
.query(statement.sql)
|
|
145
|
+
.all(...statement.args) as PropertyPermissionBatchDatabaseRow[]
|
|
146
|
+
for (const row of rows) result[row._batch_index] = true
|
|
147
|
+
return result
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
listLinks(params: {
|
|
151
|
+
projectId: string
|
|
152
|
+
objectTypeId: string
|
|
153
|
+
objectId: string
|
|
154
|
+
linkId?: string
|
|
155
|
+
direction?: LinkDirection
|
|
156
|
+
}): readonly ObjectLinkRow[] {
|
|
157
|
+
const source = this.source
|
|
158
|
+
const direction = params.direction ?? "outgoing"
|
|
159
|
+
const directionWhere =
|
|
160
|
+
direction === "incoming"
|
|
161
|
+
? "target_type_id = ? AND target_id = ?"
|
|
162
|
+
: direction === "both"
|
|
163
|
+
? "((source_type_id = ? AND source_id = ?) OR (target_type_id = ? AND target_id = ?))"
|
|
164
|
+
: "source_type_id = ? AND source_id = ?"
|
|
165
|
+
let query = `SELECT * FROM ${source.linksTable} WHERE project_id = ? AND ${directionWhere}`
|
|
166
|
+
const args: (string | number)[] =
|
|
167
|
+
direction === "both"
|
|
168
|
+
? [
|
|
169
|
+
params.projectId,
|
|
170
|
+
params.objectTypeId,
|
|
171
|
+
params.objectId,
|
|
172
|
+
params.objectTypeId,
|
|
173
|
+
params.objectId,
|
|
174
|
+
]
|
|
175
|
+
: [params.projectId, params.objectTypeId, params.objectId]
|
|
176
|
+
|
|
177
|
+
if (params.linkId) {
|
|
178
|
+
query += " AND link_id = ?"
|
|
179
|
+
args.push(params.linkId)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const statement = source.wrapStatement(query, args)
|
|
183
|
+
const rows = this.db.query(statement.sql).all(...statement.args) as LinkDatabaseRow[]
|
|
184
|
+
|
|
185
|
+
return rows.map((row) => rowToLink(row))
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
getByPrimaryIdBatch(params: {
|
|
189
|
+
projectId: string
|
|
190
|
+
items: readonly { objectTypeId: string; primaryId: string }[]
|
|
191
|
+
}): Map<ObjectBatchKey, ObjectRow> {
|
|
192
|
+
const source = this.source
|
|
193
|
+
const result = new Map<ObjectBatchKey, ObjectRow>()
|
|
194
|
+
if (params.items.length === 0) return result
|
|
195
|
+
|
|
196
|
+
const items = params.items.map((item, batchIndex) => ({ ...item, batchIndex }))
|
|
197
|
+
const statement = source.wrapStatement(
|
|
198
|
+
`
|
|
199
|
+
SELECT
|
|
200
|
+
object.*,
|
|
201
|
+
CAST(json_extract(requested.value, '$.batchIndex') AS INTEGER) AS _batch_index
|
|
202
|
+
FROM json_each(?) AS requested
|
|
203
|
+
JOIN ${source.objectsTable} AS object
|
|
204
|
+
ON object.project_id = ?
|
|
205
|
+
AND object.object_type_id = json_extract(requested.value, '$.objectTypeId')
|
|
206
|
+
AND object.primary_id = json_extract(requested.value, '$.primaryId')
|
|
207
|
+
`,
|
|
208
|
+
[JSON.stringify(items), params.projectId]
|
|
209
|
+
)
|
|
210
|
+
const rows = this.db.query(statement.sql).all(...statement.args) as ObjectBatchDatabaseRow[]
|
|
211
|
+
const rowsByIndex = new Map(rows.map((row) => [row._batch_index, row]))
|
|
212
|
+
for (const [index, item] of params.items.entries()) {
|
|
213
|
+
const row = rowsByIndex.get(index)
|
|
214
|
+
if (row) result.set(objectBatchKey(item.objectTypeId, item.primaryId), rowToObject(row))
|
|
215
|
+
}
|
|
216
|
+
return result
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
listLinksBatch(params: {
|
|
220
|
+
projectId: string
|
|
221
|
+
direction?: LinkDirection
|
|
222
|
+
items: readonly { objectTypeId: string; objectId: string; linkId: string }[]
|
|
223
|
+
}): Map<LinkBatchKey, ObjectLinkRow[]> {
|
|
224
|
+
const source = this.source
|
|
225
|
+
const result = new Map<LinkBatchKey, ObjectLinkRow[]>()
|
|
226
|
+
if (params.items.length === 0) return result
|
|
227
|
+
|
|
228
|
+
const direction = params.direction ?? "outgoing"
|
|
229
|
+
const requestedType = "CAST(json_extract(requested.value, '$.objectTypeId') AS TEXT)"
|
|
230
|
+
const requestedId = "CAST(json_extract(requested.value, '$.objectId') AS TEXT)"
|
|
231
|
+
const requestedLink = "CAST(json_extract(requested.value, '$.linkId') AS TEXT)"
|
|
232
|
+
const outgoing = `stored.source_type_id = ${requestedType} AND stored.source_id = ${requestedId}`
|
|
233
|
+
const incoming = `stored.target_type_id = ${requestedType} AND stored.target_id = ${requestedId}`
|
|
234
|
+
const directionJoin =
|
|
235
|
+
direction === "both"
|
|
236
|
+
? `((${outgoing}) OR (${incoming}))`
|
|
237
|
+
: direction === "incoming"
|
|
238
|
+
? incoming
|
|
239
|
+
: outgoing
|
|
240
|
+
const items = params.items.map((item, batchIndex) => ({ ...item, batchIndex }))
|
|
241
|
+
const statement = source.wrapStatement(
|
|
242
|
+
`
|
|
243
|
+
SELECT
|
|
244
|
+
stored.*,
|
|
245
|
+
CAST(json_extract(requested.value, '$.batchIndex') AS INTEGER) AS _batch_index
|
|
246
|
+
FROM ${source.linksTable} AS stored
|
|
247
|
+
JOIN json_each(?) AS requested
|
|
248
|
+
ON ${directionJoin}
|
|
249
|
+
AND stored.link_id = ${requestedLink}
|
|
250
|
+
WHERE stored.project_id = ?
|
|
251
|
+
ORDER BY _batch_index, source_type_id, source_id, link_id, target_type_id, target_id
|
|
252
|
+
`,
|
|
253
|
+
[JSON.stringify(items), params.projectId]
|
|
254
|
+
)
|
|
255
|
+
const rows = this.db.query(statement.sql).all(...statement.args) as LinkBatchDatabaseRow[]
|
|
256
|
+
const rowsByIndex = params.items.map(() => new Map<string, ObjectLinkRow>())
|
|
257
|
+
for (const row of rows) {
|
|
258
|
+
const link = rowToLink(row)
|
|
259
|
+
rowsByIndex[row._batch_index]?.set(fullLinkIdentity(link), link)
|
|
260
|
+
}
|
|
261
|
+
for (const [index, item] of params.items.entries()) {
|
|
262
|
+
const links = [...(rowsByIndex[index]?.values() ?? [])]
|
|
263
|
+
if (links.length > 0) {
|
|
264
|
+
result.set(linkBatchKey(item.objectTypeId, item.objectId, item.linkId), links)
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return result
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
queryLinks(params: QueryObjectLinksInput): QueryObjectLinksResult {
|
|
271
|
+
const source = this.source
|
|
272
|
+
if (params.objectRefs.length === 0 || params.endpointObjectTypeIds?.length === 0) {
|
|
273
|
+
return { links: [], hasMore: false }
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const requested = `
|
|
277
|
+
SELECT DISTINCT
|
|
278
|
+
json_extract(value, '$.objectTypeId') AS object_type_id,
|
|
279
|
+
json_extract(value, '$.primaryId') AS object_id
|
|
280
|
+
FROM json_each(?)
|
|
281
|
+
`
|
|
282
|
+
const linksTable = source.linksTable
|
|
283
|
+
const sourceJoin = `
|
|
284
|
+
SELECT link.*
|
|
285
|
+
FROM ${linksTable} AS link
|
|
286
|
+
JOIN (${requested}) AS requested
|
|
287
|
+
ON requested.object_type_id = link.source_type_id
|
|
288
|
+
AND requested.object_id = link.source_id
|
|
289
|
+
WHERE link.project_id = ?
|
|
290
|
+
`
|
|
291
|
+
const targetJoin = `
|
|
292
|
+
SELECT link.*
|
|
293
|
+
FROM ${linksTable} AS link
|
|
294
|
+
JOIN (${requested}) AS requested
|
|
295
|
+
ON requested.object_type_id = link.target_type_id
|
|
296
|
+
AND requested.object_id = link.target_id
|
|
297
|
+
WHERE link.project_id = ?
|
|
298
|
+
`
|
|
299
|
+
const incidentSql =
|
|
300
|
+
params.direction === "outgoing"
|
|
301
|
+
? sourceJoin
|
|
302
|
+
: params.direction === "incoming"
|
|
303
|
+
? targetJoin
|
|
304
|
+
: `${sourceJoin} UNION ${targetJoin}`
|
|
305
|
+
const requestedJson = JSON.stringify(params.objectRefs)
|
|
306
|
+
const args: (string | number)[] =
|
|
307
|
+
params.direction === "both"
|
|
308
|
+
? [requestedJson, params.projectId, requestedJson, params.projectId]
|
|
309
|
+
: [requestedJson, params.projectId]
|
|
310
|
+
|
|
311
|
+
const predicates: string[] = []
|
|
312
|
+
if (params.linkId !== undefined) {
|
|
313
|
+
predicates.push("link_id = ?")
|
|
314
|
+
args.push(params.linkId)
|
|
315
|
+
}
|
|
316
|
+
if (params.endpointObjectTypeIds !== undefined) {
|
|
317
|
+
const allowedTypes = JSON.stringify([...new Set(params.endpointObjectTypeIds)])
|
|
318
|
+
predicates.push(
|
|
319
|
+
"source_type_id IN (SELECT value FROM json_each(?))",
|
|
320
|
+
"target_type_id IN (SELECT value FROM json_each(?))"
|
|
321
|
+
)
|
|
322
|
+
args.push(allowedTypes, allowedTypes)
|
|
323
|
+
}
|
|
324
|
+
if (params.after) {
|
|
325
|
+
predicates.push(
|
|
326
|
+
"(source_type_id, source_id, link_id, target_type_id, target_id) > (?, ?, ?, ?, ?)"
|
|
327
|
+
)
|
|
328
|
+
args.push(...params.after)
|
|
329
|
+
}
|
|
330
|
+
args.push(params.limit + 1)
|
|
331
|
+
|
|
332
|
+
const whereSql = predicates.length > 0 ? `WHERE ${predicates.join(" AND ")}` : ""
|
|
333
|
+
const statement = source.wrapStatement(
|
|
334
|
+
`
|
|
335
|
+
SELECT *
|
|
336
|
+
FROM (${incidentSql}) AS incident
|
|
337
|
+
${whereSql}
|
|
338
|
+
ORDER BY source_type_id, source_id, link_id, target_type_id, target_id
|
|
339
|
+
LIMIT ?
|
|
340
|
+
`,
|
|
341
|
+
args
|
|
342
|
+
)
|
|
343
|
+
const rows = this.db.query(statement.sql).all(...statement.args) as LinkDatabaseRow[]
|
|
344
|
+
|
|
345
|
+
return {
|
|
346
|
+
links: rows.slice(0, params.limit).map((row) => rowToLink(row)),
|
|
347
|
+
hasMore: rows.length > params.limit,
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
list(params: Parameters<ObjectReadStorage["list"]>[0]): {
|
|
352
|
+
objects: readonly ObjectRow[]
|
|
353
|
+
hasMore: boolean
|
|
354
|
+
total: number
|
|
355
|
+
} {
|
|
356
|
+
const source = this.source
|
|
357
|
+
let query = `SELECT * FROM ${source.objectsTable} WHERE project_id = ?`
|
|
358
|
+
const args: (string | number | null)[] = [params.projectId]
|
|
359
|
+
|
|
360
|
+
if (params.objectTypeId) {
|
|
361
|
+
if (typeof params.objectTypeId === "string") {
|
|
362
|
+
query += " AND object_type_id = ?"
|
|
363
|
+
args.push(params.objectTypeId)
|
|
364
|
+
} else {
|
|
365
|
+
query += ` AND object_type_id IN (${params.objectTypeId.map(() => "?").join(", ")})`
|
|
366
|
+
args.push(...params.objectTypeId)
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (params.primaryIdPrefix) {
|
|
371
|
+
query += " AND primary_id LIKE ?"
|
|
372
|
+
args.push(`${params.primaryIdPrefix}%`)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (params.primaryIdSuffix) {
|
|
376
|
+
query += " AND primary_id LIKE ?"
|
|
377
|
+
args.push(`%${params.primaryIdSuffix}`)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (params.updatedAfter) {
|
|
381
|
+
query += " AND updated_at >= ?"
|
|
382
|
+
args.push(params.updatedAfter.toISOString())
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (params.updatedBefore) {
|
|
386
|
+
query += " AND updated_at <= ?"
|
|
387
|
+
args.push(params.updatedBefore.toISOString())
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (params.createdAfter) {
|
|
391
|
+
query += " AND created_at >= ?"
|
|
392
|
+
args.push(params.createdAfter.toISOString())
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (params.createdBefore) {
|
|
396
|
+
query += " AND created_at <= ?"
|
|
397
|
+
args.push(params.createdBefore.toISOString())
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Get total count
|
|
401
|
+
const countStatement = source.wrapStatement(
|
|
402
|
+
`SELECT COUNT(*) as total FROM (${query}) AS filtered_objects`,
|
|
403
|
+
args
|
|
404
|
+
)
|
|
405
|
+
const countResult = this.db.query(countStatement.sql).get(...countStatement.args) as {
|
|
406
|
+
total: number
|
|
407
|
+
}
|
|
408
|
+
const total = countResult.total
|
|
409
|
+
|
|
410
|
+
const offset = params.offset ?? 0
|
|
411
|
+
const limit = params.limit ?? 50
|
|
412
|
+
if (limit === 0) return { objects: [], hasMore: offset < total, total }
|
|
413
|
+
|
|
414
|
+
// Add ordering
|
|
415
|
+
const orderBy = params.orderBy ?? "updatedAt"
|
|
416
|
+
const order = params.order ?? "desc"
|
|
417
|
+
const orderColumn =
|
|
418
|
+
orderBy === "primaryId" ? "primary_id" : orderBy === "createdAt" ? "created_at" : "updated_at"
|
|
419
|
+
query += ` ORDER BY ${orderColumn} ${order.toUpperCase()}`
|
|
420
|
+
|
|
421
|
+
// Add pagination
|
|
422
|
+
query += " LIMIT ? OFFSET ?"
|
|
423
|
+
args.push(limit + 1, offset) // +1 to check for hasMore
|
|
424
|
+
|
|
425
|
+
const rowsStatement = source.wrapStatement(query, args)
|
|
426
|
+
const rows = this.db.query(rowsStatement.sql).all(...rowsStatement.args) as DatabaseRow[]
|
|
427
|
+
const hasMore = rows.length > limit
|
|
428
|
+
const objects = rows.slice(0, limit).map((row) => rowToObject(row))
|
|
429
|
+
|
|
430
|
+
return { objects, hasMore, total }
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function fullLinkIdentity(link: ObjectLinkRow): string {
|
|
435
|
+
return JSON.stringify([
|
|
436
|
+
link.sourceTypeId,
|
|
437
|
+
link.sourceId,
|
|
438
|
+
link.linkId,
|
|
439
|
+
link.targetTypeId,
|
|
440
|
+
link.targetId,
|
|
441
|
+
])
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
export function assertLinkQueryLimit(limit: number): void {
|
|
445
|
+
if (!Number.isSafeInteger(limit) || limit <= 0) {
|
|
446
|
+
throw new Error("Object link query limit must be a positive safe integer.")
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function readTotal(db: Database, compiled: CompiledObjectQuery): number {
|
|
451
|
+
const row = db.query(compiled.totalSql).get(...compiled.totalArgs) as { total: number }
|
|
452
|
+
return row.total
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function readFacetBuckets(
|
|
456
|
+
db: Database,
|
|
457
|
+
compiled: CompiledObjectQuery,
|
|
458
|
+
facet: ObjectFacetRequest
|
|
459
|
+
): { value: unknown; count: number }[] {
|
|
460
|
+
const path = sqliteJsonPath(facet.propertyId)
|
|
461
|
+
const rows = db
|
|
462
|
+
.query(
|
|
463
|
+
`
|
|
464
|
+
SELECT
|
|
465
|
+
json_type(input.properties, ?) AS value_type,
|
|
466
|
+
json_extract(input.properties, ?) AS value,
|
|
467
|
+
COUNT(*) AS count
|
|
468
|
+
FROM (${compiled.sql}) AS input
|
|
469
|
+
WHERE json_type(input.properties, ?) IS NOT NULL
|
|
470
|
+
GROUP BY value_type, value
|
|
471
|
+
ORDER BY count DESC, CAST(value AS TEXT) ASC
|
|
472
|
+
LIMIT ?
|
|
473
|
+
`
|
|
474
|
+
)
|
|
475
|
+
.all(path, path, ...compiled.args, path, facet.limit) as FacetDatabaseRow[]
|
|
476
|
+
|
|
477
|
+
return rows.map((row) => ({
|
|
478
|
+
value: sqliteFacetValue(row.value_type, row.value),
|
|
479
|
+
count: Number(row.count),
|
|
480
|
+
}))
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function sqliteJsonPath(propertyId: string): string {
|
|
484
|
+
return `$."${propertyId.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function sqliteFacetValue(valueType: string | null, value: unknown): unknown {
|
|
488
|
+
if (valueType === "true") return true
|
|
489
|
+
if (valueType === "false") return false
|
|
490
|
+
if (valueType === "null") return null
|
|
491
|
+
return value
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function existsProbeQuery(query: ObjectQuery): ObjectQuery {
|
|
495
|
+
return { kind: "limit", limit: 1, input: stripOuterRowShape(query) }
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function stripOuterRowShape(query: ObjectQuery): ObjectQuery {
|
|
499
|
+
switch (query.kind) {
|
|
500
|
+
case "limit":
|
|
501
|
+
case "page":
|
|
502
|
+
case "project":
|
|
503
|
+
case "sort":
|
|
504
|
+
return stripOuterRowShape(query.input)
|
|
505
|
+
default:
|
|
506
|
+
return query
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
interface ObjectBatchDatabaseRow extends DatabaseRow {
|
|
511
|
+
_batch_index: number
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
interface PropertyPermissionBatchDatabaseRow {
|
|
515
|
+
_batch_index: number
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
interface FacetDatabaseRow {
|
|
519
|
+
value_type: string | null
|
|
520
|
+
value: unknown
|
|
521
|
+
count: number
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
interface LinkBatchDatabaseRow extends LinkDatabaseRow {
|
|
525
|
+
_batch_index: number
|
|
526
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExpandedLinkValue,
|
|
3
|
+
ExpandedObjectRow,
|
|
4
|
+
ObjectLinkRow,
|
|
5
|
+
ObjectRow,
|
|
6
|
+
ObjectRowLinks,
|
|
7
|
+
} from "@sixb/core/storage"
|
|
8
|
+
|
|
9
|
+
export function rowToObject(row: DatabaseRow): ObjectRow {
|
|
10
|
+
return {
|
|
11
|
+
projectId: row.project_id,
|
|
12
|
+
objectTypeId: row.object_type_id,
|
|
13
|
+
primaryId: row.primary_id,
|
|
14
|
+
properties: JSON.parse(row.properties),
|
|
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
|
+
/** Decode an expanded query row with the same shape as Core link hydration. */
|
|
23
|
+
export function queryRowToObject(row: ObjectQueryDatabaseRow): ObjectRow {
|
|
24
|
+
const base = rowToObject(row)
|
|
25
|
+
const links = reviveExpandedLinks(parseExpandColumn(row._expand))
|
|
26
|
+
return links ? { ...base, links } : base
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function rowToLink(row: LinkDatabaseRow): ObjectLinkRow {
|
|
30
|
+
return {
|
|
31
|
+
projectId: row.project_id,
|
|
32
|
+
sourceTypeId: row.source_type_id,
|
|
33
|
+
sourceId: row.source_id,
|
|
34
|
+
linkId: row.link_id,
|
|
35
|
+
targetTypeId: row.target_type_id,
|
|
36
|
+
targetId: row.target_id,
|
|
37
|
+
...(row.properties === null ? {} : { properties: JSON.parse(row.properties) }),
|
|
38
|
+
createdAt: new Date(row.created_at),
|
|
39
|
+
updatedAt: new Date(row.updated_at),
|
|
40
|
+
lastCommitId: row.last_commit_id,
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The `_expand` column comes back from SQLite as a JSON string (or null/absent
|
|
45
|
+
// when the query carried no expansion); parse it before reviving.
|
|
46
|
+
function parseExpandColumn(value: unknown): unknown {
|
|
47
|
+
if (typeof value !== "string") return value ?? undefined
|
|
48
|
+
return JSON.parse(value)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function reviveExpandedLinks(value: unknown): ObjectRowLinks | undefined {
|
|
52
|
+
if (!isPlainObject(value)) return undefined
|
|
53
|
+
const links: ObjectRowLinks = {}
|
|
54
|
+
for (const [linkId, raw] of Object.entries(value)) {
|
|
55
|
+
links[linkId] = reviveExpandedLinkValue(raw)
|
|
56
|
+
}
|
|
57
|
+
return links
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// A "one" expansion arrives as a single object or null; a "many" expansion as an
|
|
61
|
+
// array (already ordered and trimmed in the database).
|
|
62
|
+
function reviveExpandedLinkValue(value: unknown): ExpandedLinkValue {
|
|
63
|
+
if (value === null || value === undefined) return null
|
|
64
|
+
if (Array.isArray(value)) return value.map(reviveExpandedRow)
|
|
65
|
+
return reviveExpandedRow(value)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Revive one hydrated neighbour from `compileExpansionChildJson`: timestamp
|
|
69
|
+
// strings back to `Date`, null/empty link properties dropped (matching the
|
|
70
|
+
// fallback), and nested links recursed.
|
|
71
|
+
function reviveExpandedRow(value: unknown): ExpandedObjectRow {
|
|
72
|
+
const row = isPlainObject(value) ? value : {}
|
|
73
|
+
const expanded: ExpandedObjectRow = {
|
|
74
|
+
projectId: String(row.projectId),
|
|
75
|
+
objectTypeId: String(row.objectTypeId),
|
|
76
|
+
primaryId: String(row.primaryId),
|
|
77
|
+
properties: isPlainObject(row.properties) ? row.properties : {},
|
|
78
|
+
createdAt: new Date(row.createdAt as string),
|
|
79
|
+
updatedAt: new Date(row.updatedAt as string),
|
|
80
|
+
version: Number(row.version),
|
|
81
|
+
lastCommitId: String(row.lastCommitId),
|
|
82
|
+
}
|
|
83
|
+
if (isPlainObject(row.linkProperties) && Object.keys(row.linkProperties).length > 0) {
|
|
84
|
+
expanded.linkProperties = row.linkProperties
|
|
85
|
+
}
|
|
86
|
+
const links = reviveExpandedLinks(row.links)
|
|
87
|
+
if (links) expanded.links = links
|
|
88
|
+
return expanded
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
92
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface DatabaseRow {
|
|
96
|
+
project_id: string
|
|
97
|
+
object_type_id: string
|
|
98
|
+
primary_id: string
|
|
99
|
+
properties: string
|
|
100
|
+
created_at: string
|
|
101
|
+
updated_at: string
|
|
102
|
+
version: number
|
|
103
|
+
last_commit_id: string
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface ObjectQueryDatabaseRow extends DatabaseRow {
|
|
107
|
+
_cursor_properties?: string
|
|
108
|
+
/** `json_object(linkId, value, ...)` serialized text from an `expand` pushdown; absent otherwise. */
|
|
109
|
+
_expand?: string | null
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface LinkDatabaseRow {
|
|
113
|
+
project_id: string
|
|
114
|
+
source_type_id: string
|
|
115
|
+
source_id: string
|
|
116
|
+
link_id: string
|
|
117
|
+
target_type_id: string
|
|
118
|
+
target_id: string
|
|
119
|
+
properties: string | null
|
|
120
|
+
created_at: string
|
|
121
|
+
updated_at: string
|
|
122
|
+
last_commit_id: string
|
|
123
|
+
}
|