@kubun/graphql 0.10.1 → 0.12.0
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/lib/context.d.ts +31 -2
- package/lib/context.js +620 -1
- package/lib/identifiers.js +42 -1
- package/lib/index.js +8 -1
- package/lib/scalars.js +24 -1
- package/lib/schema.js +2484 -1
- package/lib/types.js +1 -1
- package/lib/utils.js +42 -1
- package/package.json +25 -24
package/lib/context.js
CHANGED
|
@@ -1 +1,620 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { DocumentID } from '@kubun/id';
|
|
2
|
+
import { getKubunLogger } from '@kubun/logger';
|
|
3
|
+
import { KubunAttributeKeys, KubunSpanNames } from '@kubun/protocol';
|
|
4
|
+
import { defer } from '@sozai/async';
|
|
5
|
+
import { withSpan } from '@sozai/otel';
|
|
6
|
+
/**
|
|
7
|
+
* Default cap on the number of undelivered subscription events buffered per
|
|
8
|
+
* generator. When a consumer falls behind, the oldest events are evicted so a
|
|
9
|
+
* single slow subscriber cannot grow memory without bound.
|
|
10
|
+
*/ const DEFAULT_SUBSCRIPTION_QUEUE_SIZE = 1000;
|
|
11
|
+
/** Access checks in flight at once — see `filterByAccess`. */ const ACCESS_CHECK_CONCURRENCY = 25;
|
|
12
|
+
/**
|
|
13
|
+
* Minimum spacing between repeated overflow warnings for a single generator, so
|
|
14
|
+
* a sustained overflow logs at most once per interval instead of per drop.
|
|
15
|
+
*/ const SUBSCRIPTION_OVERFLOW_WARN_INTERVAL_MS = 5000;
|
|
16
|
+
/**
|
|
17
|
+
* Default ceiling on raw rows scanned when exhausting an unbounded
|
|
18
|
+
* access-controlled connection query. The query pages the store and filters by
|
|
19
|
+
* access until the store is exhausted or this many rows have been examined,
|
|
20
|
+
* bounding work on large datasets at the cost of an honest truncation signal.
|
|
21
|
+
*/ const DEFAULT_UNBOUNDED_SCAN_CAP = 5000;
|
|
22
|
+
/**
|
|
23
|
+
* Batch size used to page the store while exhausting an unbounded
|
|
24
|
+
* access-controlled connection query.
|
|
25
|
+
*/ const UNBOUNDED_SCAN_BATCH_SIZE = 100;
|
|
26
|
+
const logger = getKubunLogger('graphql');
|
|
27
|
+
export function createReadContext(params) {
|
|
28
|
+
const { store, viewerDID, accessChecker, viewerReadAccess } = params;
|
|
29
|
+
/**
|
|
30
|
+
* Keep the documents a viewer may read, in their original order.
|
|
31
|
+
*
|
|
32
|
+
* The checks are independent reads over one viewer, so they run concurrently;
|
|
33
|
+
* checking in sequence only ever bought the order the denial warnings were
|
|
34
|
+
* pushed in, and filtering after every verdict is in preserves that too.
|
|
35
|
+
*/ async function filterByAccess(documents, check) {
|
|
36
|
+
// Concurrent but bounded. Each check issues its own reads, and the page
|
|
37
|
+
// reaching here can be as large as `unboundedScanCap` (5000) — one
|
|
38
|
+
// `Promise.all` over that queues five thousand reads onto a single SQLite
|
|
39
|
+
// connection at once, which is slower than the sequential version it
|
|
40
|
+
// replaced and starves everything else on that connection.
|
|
41
|
+
const verdicts = [];
|
|
42
|
+
for(let index = 0; index < documents.length; index += ACCESS_CHECK_CONCURRENCY){
|
|
43
|
+
const batch = documents.slice(index, index + ACCESS_CHECK_CONCURRENCY);
|
|
44
|
+
verdicts.push(...await Promise.all(batch.map((doc)=>check(doc, 'read'))));
|
|
45
|
+
}
|
|
46
|
+
return documents.filter((doc, index)=>{
|
|
47
|
+
if (verdicts[index]) {
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
params.warnings?.push({
|
|
51
|
+
message: `Access denied for document ${doc.id}`,
|
|
52
|
+
code: 'KB07'
|
|
53
|
+
});
|
|
54
|
+
return false;
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
function traced(name, attributes, fn) {
|
|
58
|
+
if (params.tracer != null) {
|
|
59
|
+
return withSpan(params.tracer, name, {
|
|
60
|
+
attributes
|
|
61
|
+
}, async ()=>fn());
|
|
62
|
+
}
|
|
63
|
+
return fn();
|
|
64
|
+
}
|
|
65
|
+
const maxSubscriptionQueueSize = params.maxSubscriptionQueueSize ?? DEFAULT_SUBSCRIPTION_QUEUE_SIZE;
|
|
66
|
+
function createEventGenerator(filter, transform) {
|
|
67
|
+
let isDone = false;
|
|
68
|
+
let pending = null;
|
|
69
|
+
const queue = [];
|
|
70
|
+
// Overflow warning throttle: warn on the first eviction, then at most once
|
|
71
|
+
// per interval, reporting how many events were dropped since the last warn.
|
|
72
|
+
let droppedSinceWarn = 0;
|
|
73
|
+
let lastWarnAt = 0;
|
|
74
|
+
let warnedOnce = false;
|
|
75
|
+
const recordDrop = ()=>{
|
|
76
|
+
droppedSinceWarn++;
|
|
77
|
+
const now = Date.now();
|
|
78
|
+
if (warnedOnce && now - lastWarnAt < SUBSCRIPTION_OVERFLOW_WARN_INTERVAL_MS) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
logger.warn('subscription event queue overflow: dropped {dropped} event(s) (max queue size {max}); newest events retained', {
|
|
82
|
+
dropped: droppedSinceWarn,
|
|
83
|
+
max: maxSubscriptionQueueSize
|
|
84
|
+
});
|
|
85
|
+
warnedOnce = true;
|
|
86
|
+
lastWarnAt = now;
|
|
87
|
+
droppedSinceWarn = 0;
|
|
88
|
+
};
|
|
89
|
+
if (params.events == null) {
|
|
90
|
+
throw new Error('Subscriptions require a document event emitter');
|
|
91
|
+
}
|
|
92
|
+
const stop = params.events.on('document:saved', (event)=>{
|
|
93
|
+
if (filter(event)) {
|
|
94
|
+
const toPush = transform(event);
|
|
95
|
+
if (pending == null) {
|
|
96
|
+
queue.push(toPush);
|
|
97
|
+
// Bounded buffer: evict the oldest so the newest events are retained.
|
|
98
|
+
if (queue.length > maxSubscriptionQueueSize) {
|
|
99
|
+
queue.shift();
|
|
100
|
+
recordDrop();
|
|
101
|
+
}
|
|
102
|
+
} else {
|
|
103
|
+
pending.resolve({
|
|
104
|
+
value: toPush,
|
|
105
|
+
done: false
|
|
106
|
+
});
|
|
107
|
+
pending = null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
const setDone = ()=>{
|
|
112
|
+
// Settle any in-flight `next()` to done before tearing down so a parked
|
|
113
|
+
// consumer resolves instead of hanging after return()/throw()/dispose.
|
|
114
|
+
if (pending != null) {
|
|
115
|
+
pending.resolve({
|
|
116
|
+
done: true,
|
|
117
|
+
value: undefined
|
|
118
|
+
});
|
|
119
|
+
pending = null;
|
|
120
|
+
}
|
|
121
|
+
stop();
|
|
122
|
+
isDone = true;
|
|
123
|
+
return Promise.resolve({
|
|
124
|
+
done: true,
|
|
125
|
+
value: undefined
|
|
126
|
+
});
|
|
127
|
+
};
|
|
128
|
+
return {
|
|
129
|
+
[Symbol.asyncDispose] () {
|
|
130
|
+
setDone();
|
|
131
|
+
return Promise.resolve();
|
|
132
|
+
},
|
|
133
|
+
[Symbol.asyncIterator] () {
|
|
134
|
+
return this;
|
|
135
|
+
},
|
|
136
|
+
next: ()=>{
|
|
137
|
+
if (isDone) {
|
|
138
|
+
return Promise.resolve({
|
|
139
|
+
done: true,
|
|
140
|
+
value: undefined
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
if (queue.length > 0) {
|
|
144
|
+
return Promise.resolve({
|
|
145
|
+
value: queue.shift(),
|
|
146
|
+
done: false
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
pending = defer();
|
|
150
|
+
return pending.promise;
|
|
151
|
+
},
|
|
152
|
+
return: setDone,
|
|
153
|
+
throw: setDone
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
// Wraps an eagerly-subscribed base generator with an access-control filter.
|
|
157
|
+
// A bare `async function*` wrapper would skip its body (and any cleanup) when
|
|
158
|
+
// `return()` is called before the first `next()`, leaking the base
|
|
159
|
+
// generator's `document:saved` listener. Closing the base explicitly on
|
|
160
|
+
// `return()`/`throw()` guarantees teardown even for a pre-start cancellation.
|
|
161
|
+
function withAccessCheck(base, toDocument) {
|
|
162
|
+
const check = accessChecker;
|
|
163
|
+
if (check == null) {
|
|
164
|
+
return base;
|
|
165
|
+
}
|
|
166
|
+
const filtered = async function*() {
|
|
167
|
+
for await (const value of base){
|
|
168
|
+
if (await check(toDocument(value), 'read')) {
|
|
169
|
+
yield value;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}();
|
|
173
|
+
return {
|
|
174
|
+
[Symbol.asyncIterator] () {
|
|
175
|
+
return this;
|
|
176
|
+
},
|
|
177
|
+
async [Symbol.asyncDispose] () {
|
|
178
|
+
await base.return?.(undefined);
|
|
179
|
+
},
|
|
180
|
+
next: ()=>filtered.next(),
|
|
181
|
+
return: async (value)=>{
|
|
182
|
+
await base.return?.(value);
|
|
183
|
+
return filtered.return(undefined);
|
|
184
|
+
},
|
|
185
|
+
throw: async (error)=>{
|
|
186
|
+
await base.return?.(undefined);
|
|
187
|
+
return filtered.throw(error);
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
getViewer () {
|
|
193
|
+
return viewerDID;
|
|
194
|
+
},
|
|
195
|
+
async loadDocument (id) {
|
|
196
|
+
const resolverKey = `document:${id}`;
|
|
197
|
+
const start = params.complexity?.resolverTimings != null ? performance.now() : 0;
|
|
198
|
+
const result = await traced(KubunSpanNames.DATA_QUERY, {
|
|
199
|
+
[KubunAttributeKeys.DOCUMENT_ID]: id
|
|
200
|
+
}, async ()=>{
|
|
201
|
+
const doc = await store.getDocument(DocumentID.fromString(id));
|
|
202
|
+
// Tombstone (null data) reads as not-found here, matching connections;
|
|
203
|
+
// the row is kept on disk for sync/CRDT.
|
|
204
|
+
if (!doc || doc.data == null) return null;
|
|
205
|
+
// Check access if access checker is provided
|
|
206
|
+
if (accessChecker) {
|
|
207
|
+
const hasAccess = await accessChecker(doc, 'read');
|
|
208
|
+
if (!hasAccess) {
|
|
209
|
+
if (params.warnings != null) {
|
|
210
|
+
params.warnings.push({
|
|
211
|
+
message: `Access denied for document ${id}`,
|
|
212
|
+
code: 'KB07'
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (params.complexity != null) {
|
|
219
|
+
params.complexity.resolverCount++;
|
|
220
|
+
params.complexity.documentsScanned++;
|
|
221
|
+
}
|
|
222
|
+
return doc;
|
|
223
|
+
});
|
|
224
|
+
if (params.complexity?.resolverTimings != null) {
|
|
225
|
+
const elapsed = Math.round((performance.now() - start) * 100) / 100;
|
|
226
|
+
const existing = params.complexity.resolverTimings.get(resolverKey) ?? 0;
|
|
227
|
+
params.complexity.resolverTimings.set(resolverKey, existing + elapsed);
|
|
228
|
+
}
|
|
229
|
+
return result;
|
|
230
|
+
},
|
|
231
|
+
async searchDocuments (query, modelIDs, first) {
|
|
232
|
+
return await store.searchDocuments({
|
|
233
|
+
query,
|
|
234
|
+
modelIDs,
|
|
235
|
+
first
|
|
236
|
+
});
|
|
237
|
+
},
|
|
238
|
+
async listModelAccessDefaults () {
|
|
239
|
+
return await store.listUserModelAccessDefaults(viewerDID);
|
|
240
|
+
},
|
|
241
|
+
async resolveConnection (modelIDs, args) {
|
|
242
|
+
const resolverKey = `connection:${modelIDs.join(',')}`;
|
|
243
|
+
const start = params.complexity?.resolverTimings != null ? performance.now() : 0;
|
|
244
|
+
// Captured once so the `aggregates` child resolver re-runs against the same
|
|
245
|
+
// documents the page was built from, independent of pagination cursors.
|
|
246
|
+
const aggregateScope = {
|
|
247
|
+
modelIDs,
|
|
248
|
+
filter: args.filter,
|
|
249
|
+
owner: args.owner,
|
|
250
|
+
catalogID: params.catalogID
|
|
251
|
+
};
|
|
252
|
+
const result = await traced(KubunSpanNames.DATA_QUERY, {
|
|
253
|
+
[KubunAttributeKeys.MODEL_ID]: modelIDs.join(',')
|
|
254
|
+
}, async ()=>{
|
|
255
|
+
// Handle search: fetch matching IDs, then load documents
|
|
256
|
+
if (args.search != null && args.search.trim() !== '') {
|
|
257
|
+
const searchResults = await store.searchDocuments({
|
|
258
|
+
query: args.search,
|
|
259
|
+
modelIDs,
|
|
260
|
+
first: 200
|
|
261
|
+
});
|
|
262
|
+
if (searchResults.length === 0) {
|
|
263
|
+
if (params.complexity != null) {
|
|
264
|
+
params.complexity.resolverCount++;
|
|
265
|
+
for (const id of modelIDs){
|
|
266
|
+
params.complexity.modelsQueried.add(id);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return {
|
|
270
|
+
edges: [],
|
|
271
|
+
pageInfo: {
|
|
272
|
+
hasNextPage: false,
|
|
273
|
+
hasPreviousPage: false,
|
|
274
|
+
startCursor: null,
|
|
275
|
+
endCursor: null
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
const matchingIDs = searchResults.map((r)=>r.documentID);
|
|
280
|
+
const documents = await store.listDocuments({
|
|
281
|
+
modelIDs,
|
|
282
|
+
docIDs: matchingIDs
|
|
283
|
+
});
|
|
284
|
+
// Apply access control
|
|
285
|
+
let accessible = documents;
|
|
286
|
+
if (accessChecker) {
|
|
287
|
+
accessible = await filterByAccess(documents, accessChecker);
|
|
288
|
+
}
|
|
289
|
+
// Order by search rank
|
|
290
|
+
const rankMap = new Map(searchResults.map((r)=>[
|
|
291
|
+
r.documentID,
|
|
292
|
+
r.rank
|
|
293
|
+
]));
|
|
294
|
+
accessible.sort((a, b)=>Math.abs(rankMap.get(b.id) ?? 0) - Math.abs(rankMap.get(a.id) ?? 0));
|
|
295
|
+
const limited = accessible.slice(0, args.first ?? 50);
|
|
296
|
+
if (params.complexity != null) {
|
|
297
|
+
params.complexity.resolverCount++;
|
|
298
|
+
for (const id of modelIDs){
|
|
299
|
+
params.complexity.modelsQueried.add(id);
|
|
300
|
+
}
|
|
301
|
+
params.complexity.documentsScanned += limited.length;
|
|
302
|
+
}
|
|
303
|
+
return {
|
|
304
|
+
edges: limited.map((doc, i)=>({
|
|
305
|
+
cursor: String(i),
|
|
306
|
+
node: doc
|
|
307
|
+
})),
|
|
308
|
+
pageInfo: {
|
|
309
|
+
hasNextPage: accessible.length > limited.length,
|
|
310
|
+
hasPreviousPage: false,
|
|
311
|
+
startCursor: limited.length > 0 ? '0' : null,
|
|
312
|
+
endCursor: limited.length > 0 ? String(limited.length - 1) : null
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
// If no access checker, return all documents
|
|
317
|
+
if (accessChecker == null) {
|
|
318
|
+
const { entries, hasMore } = await store.queryDocuments({
|
|
319
|
+
...args,
|
|
320
|
+
modelIDs,
|
|
321
|
+
catalogID: params.catalogID
|
|
322
|
+
});
|
|
323
|
+
if (params.complexity != null) {
|
|
324
|
+
params.complexity.resolverCount++;
|
|
325
|
+
for (const id of modelIDs){
|
|
326
|
+
params.complexity.modelsQueried.add(id);
|
|
327
|
+
}
|
|
328
|
+
params.complexity.documentsScanned += entries.length;
|
|
329
|
+
}
|
|
330
|
+
return {
|
|
331
|
+
edges: entries.map((e)=>({
|
|
332
|
+
cursor: e.cursor,
|
|
333
|
+
node: e.document
|
|
334
|
+
})),
|
|
335
|
+
pageInfo: {
|
|
336
|
+
hasNextPage: args.first ? hasMore : false,
|
|
337
|
+
hasPreviousPage: args.first ? false : hasMore,
|
|
338
|
+
endCursor: entries.at(-1)?.cursor ?? null,
|
|
339
|
+
startCursor: entries.at(0)?.cursor ?? null
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
// With access control: post-fetch filtering with over-fetching
|
|
344
|
+
const allEntries = [];
|
|
345
|
+
let requestedCount = 0;
|
|
346
|
+
let attempts = 0;
|
|
347
|
+
const maxAttempts = 10 // Prevent infinite loops
|
|
348
|
+
;
|
|
349
|
+
// Set when an unbounded query stops at the scan ceiling rather than
|
|
350
|
+
// exhausting the store, so the page reports more results remain.
|
|
351
|
+
let unboundedTruncated = false;
|
|
352
|
+
if (args.first == null && args.last == null) {
|
|
353
|
+
// An unbounded access-controlled query must return every readable
|
|
354
|
+
// document, not just the readable ones within the store's default
|
|
355
|
+
// page. Page the store and filter by access until it is exhausted or
|
|
356
|
+
// the scan ceiling is reached, signalling truncation when capped.
|
|
357
|
+
const scanCap = params.unboundedScanCap ?? DEFAULT_UNBOUNDED_SCAN_CAP;
|
|
358
|
+
let cursor = args.after;
|
|
359
|
+
let rowsScanned = 0;
|
|
360
|
+
while(rowsScanned < scanCap){
|
|
361
|
+
const { entries, hasMore } = await store.queryDocuments({
|
|
362
|
+
...args,
|
|
363
|
+
modelIDs,
|
|
364
|
+
catalogID: params.catalogID,
|
|
365
|
+
first: UNBOUNDED_SCAN_BATCH_SIZE,
|
|
366
|
+
after: cursor
|
|
367
|
+
});
|
|
368
|
+
if (entries.length === 0) break;
|
|
369
|
+
for (const entry of entries){
|
|
370
|
+
rowsScanned++;
|
|
371
|
+
if (await accessChecker(entry.document, 'read')) {
|
|
372
|
+
allEntries.push(entry);
|
|
373
|
+
} else if (params.warnings != null) {
|
|
374
|
+
params.warnings.push({
|
|
375
|
+
message: `Access denied for document ${entry.document.id}`,
|
|
376
|
+
code: 'KB07'
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
if (rowsScanned >= scanCap) break;
|
|
380
|
+
}
|
|
381
|
+
cursor = entries.at(-1)?.cursor ?? null;
|
|
382
|
+
if (!hasMore) break;
|
|
383
|
+
if (rowsScanned >= scanCap) {
|
|
384
|
+
// Stopped by the ceiling with the store not yet exhausted.
|
|
385
|
+
unboundedTruncated = true;
|
|
386
|
+
if (params.warnings != null) {
|
|
387
|
+
params.warnings.push({
|
|
388
|
+
message: `Query results truncated after scanning ${scanCap} documents; refine the query or paginate with first/last to retrieve the remainder`,
|
|
389
|
+
code: 'KB14'
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
break;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
requestedCount = allEntries.length;
|
|
396
|
+
} else if (args.first != null) {
|
|
397
|
+
requestedCount = args.first;
|
|
398
|
+
const fetchSize = requestedCount * 2 // Over-fetch to reduce round trips
|
|
399
|
+
;
|
|
400
|
+
let cursor = args.after;
|
|
401
|
+
while(allEntries.length < requestedCount && attempts < maxAttempts){
|
|
402
|
+
// Fetch unfiltered batch from DB
|
|
403
|
+
const { entries, hasMore } = await store.queryDocuments({
|
|
404
|
+
...args,
|
|
405
|
+
modelIDs,
|
|
406
|
+
catalogID: params.catalogID,
|
|
407
|
+
first: fetchSize,
|
|
408
|
+
after: cursor
|
|
409
|
+
});
|
|
410
|
+
if (entries.length === 0 && !hasMore) break;
|
|
411
|
+
// Filter based on access rules
|
|
412
|
+
for (const entry of entries){
|
|
413
|
+
if (await accessChecker(entry.document, 'read')) {
|
|
414
|
+
allEntries.push(entry);
|
|
415
|
+
if (allEntries.length >= requestedCount) break;
|
|
416
|
+
} else if (params.warnings != null) {
|
|
417
|
+
params.warnings.push({
|
|
418
|
+
message: `Access denied for document ${entry.document.id}`,
|
|
419
|
+
code: 'KB07'
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
if (!hasMore) break;
|
|
424
|
+
cursor = entries.at(-1)?.cursor ?? null;
|
|
425
|
+
attempts++;
|
|
426
|
+
}
|
|
427
|
+
} else if (args.last != null) {
|
|
428
|
+
requestedCount = args.last;
|
|
429
|
+
const fetchSize = requestedCount * 2 // Over-fetch to reduce round trips
|
|
430
|
+
;
|
|
431
|
+
let cursor = args.before;
|
|
432
|
+
while(allEntries.length < requestedCount && attempts < maxAttempts){
|
|
433
|
+
// Fetch unfiltered batch from DB
|
|
434
|
+
const { entries, hasMore } = await store.queryDocuments({
|
|
435
|
+
...args,
|
|
436
|
+
modelIDs,
|
|
437
|
+
catalogID: params.catalogID,
|
|
438
|
+
last: fetchSize,
|
|
439
|
+
before: cursor
|
|
440
|
+
});
|
|
441
|
+
if (entries.length === 0 && !hasMore) break;
|
|
442
|
+
// Filter based on access rules - note we reverse the entries array for backward pagination
|
|
443
|
+
for (const entry of [
|
|
444
|
+
...entries
|
|
445
|
+
].reverse()){
|
|
446
|
+
if (await accessChecker(entry.document, 'read')) {
|
|
447
|
+
allEntries.unshift(entry);
|
|
448
|
+
if (allEntries.length >= requestedCount) break;
|
|
449
|
+
} else if (params.warnings != null) {
|
|
450
|
+
params.warnings.push({
|
|
451
|
+
message: `Access denied for document ${entry.document.id}`,
|
|
452
|
+
code: 'KB07'
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
if (!hasMore) break;
|
|
457
|
+
cursor = entries.at(0)?.cursor ?? null;
|
|
458
|
+
attempts++;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
// Return only requested count
|
|
462
|
+
const resultEntries = allEntries.slice(0, requestedCount);
|
|
463
|
+
const hasMoreResults = allEntries.length > requestedCount;
|
|
464
|
+
if (params.complexity != null) {
|
|
465
|
+
params.complexity.resolverCount++;
|
|
466
|
+
for (const id of modelIDs){
|
|
467
|
+
params.complexity.modelsQueried.add(id);
|
|
468
|
+
}
|
|
469
|
+
params.complexity.documentsScanned += resultEntries.length;
|
|
470
|
+
}
|
|
471
|
+
return {
|
|
472
|
+
edges: resultEntries.map((e)=>({
|
|
473
|
+
cursor: e.cursor,
|
|
474
|
+
node: e.document
|
|
475
|
+
})),
|
|
476
|
+
pageInfo: {
|
|
477
|
+
hasNextPage: args.first ? hasMoreResults : unboundedTruncated,
|
|
478
|
+
hasPreviousPage: args.first ? false : hasMoreResults,
|
|
479
|
+
endCursor: resultEntries.at(-1)?.cursor ?? null,
|
|
480
|
+
startCursor: resultEntries.at(0)?.cursor ?? null
|
|
481
|
+
}
|
|
482
|
+
};
|
|
483
|
+
});
|
|
484
|
+
if (params.complexity?.resolverTimings != null) {
|
|
485
|
+
const elapsed = Math.round((performance.now() - start) * 100) / 100;
|
|
486
|
+
const existing = params.complexity.resolverTimings.get(resolverKey) ?? 0;
|
|
487
|
+
params.complexity.resolverTimings.set(resolverKey, existing + elapsed);
|
|
488
|
+
}
|
|
489
|
+
return {
|
|
490
|
+
...result,
|
|
491
|
+
aggregateScope
|
|
492
|
+
};
|
|
493
|
+
},
|
|
494
|
+
async resolveList (modelIDs, docIDs) {
|
|
495
|
+
const resolverKey = `list:${modelIDs.join(',')}`;
|
|
496
|
+
const start = params.complexity?.resolverTimings != null ? performance.now() : 0;
|
|
497
|
+
const result = await traced(KubunSpanNames.DATA_QUERY, {
|
|
498
|
+
[KubunAttributeKeys.MODEL_ID]: modelIDs.join(',')
|
|
499
|
+
}, async ()=>{
|
|
500
|
+
const documents = await store.listDocuments({
|
|
501
|
+
modelIDs,
|
|
502
|
+
docIDs
|
|
503
|
+
});
|
|
504
|
+
// If no access checker, return all documents
|
|
505
|
+
if (!accessChecker) {
|
|
506
|
+
if (params.complexity != null) {
|
|
507
|
+
params.complexity.resolverCount++;
|
|
508
|
+
for (const id of modelIDs){
|
|
509
|
+
params.complexity.modelsQueried.add(id);
|
|
510
|
+
}
|
|
511
|
+
params.complexity.documentsScanned += documents.length;
|
|
512
|
+
}
|
|
513
|
+
return documents;
|
|
514
|
+
}
|
|
515
|
+
// Filter based on access control
|
|
516
|
+
const accessible = await filterByAccess(documents, accessChecker);
|
|
517
|
+
if (params.complexity != null) {
|
|
518
|
+
params.complexity.resolverCount++;
|
|
519
|
+
for (const id of modelIDs){
|
|
520
|
+
params.complexity.modelsQueried.add(id);
|
|
521
|
+
}
|
|
522
|
+
params.complexity.documentsScanned += accessible.length;
|
|
523
|
+
}
|
|
524
|
+
return accessible;
|
|
525
|
+
});
|
|
526
|
+
if (params.complexity?.resolverTimings != null) {
|
|
527
|
+
const elapsed = Math.round((performance.now() - start) * 100) / 100;
|
|
528
|
+
const existing = params.complexity.resolverTimings.get(resolverKey) ?? 0;
|
|
529
|
+
params.complexity.resolverTimings.set(resolverKey, existing + elapsed);
|
|
530
|
+
}
|
|
531
|
+
return result;
|
|
532
|
+
},
|
|
533
|
+
async resolveAggregate (modelIDs, aggregateParams) {
|
|
534
|
+
const resolverKey = `aggregate:${modelIDs.join(',')}`;
|
|
535
|
+
const start = params.complexity?.resolverTimings != null ? performance.now() : 0;
|
|
536
|
+
const result = await traced(KubunSpanNames.DATA_QUERY, {
|
|
537
|
+
[KubunAttributeKeys.MODEL_ID]: modelIDs.join(',')
|
|
538
|
+
}, async ()=>{
|
|
539
|
+
if (params.complexity != null) {
|
|
540
|
+
params.complexity.resolverCount++;
|
|
541
|
+
}
|
|
542
|
+
return await store.aggregateDocuments({
|
|
543
|
+
modelIDs,
|
|
544
|
+
filter: aggregateParams.filter,
|
|
545
|
+
owner: aggregateParams.owner,
|
|
546
|
+
catalogID: aggregateParams.catalogID ?? params.catalogID,
|
|
547
|
+
count: aggregateParams.count,
|
|
548
|
+
aggregates: aggregateParams.aggregates,
|
|
549
|
+
readAccess: viewerReadAccess
|
|
550
|
+
});
|
|
551
|
+
});
|
|
552
|
+
if (params.complexity?.resolverTimings != null) {
|
|
553
|
+
const elapsed = Math.round((performance.now() - start) * 100) / 100;
|
|
554
|
+
const existing = params.complexity.resolverTimings.get(resolverKey) ?? 0;
|
|
555
|
+
params.complexity.resolverTimings.set(resolverKey, existing + elapsed);
|
|
556
|
+
}
|
|
557
|
+
return result;
|
|
558
|
+
},
|
|
559
|
+
async resolveGroupedAggregate (modelIDs, groupedParams) {
|
|
560
|
+
const resolverKey = `grouped-aggregate:${modelIDs.join(',')}`;
|
|
561
|
+
const start = params.complexity?.resolverTimings != null ? performance.now() : 0;
|
|
562
|
+
const result = await traced(KubunSpanNames.DATA_QUERY, {
|
|
563
|
+
[KubunAttributeKeys.MODEL_ID]: modelIDs.join(',')
|
|
564
|
+
}, async ()=>{
|
|
565
|
+
if (params.complexity != null) {
|
|
566
|
+
params.complexity.resolverCount++;
|
|
567
|
+
}
|
|
568
|
+
return await store.groupedAggregateDocuments({
|
|
569
|
+
modelIDs,
|
|
570
|
+
filter: groupedParams.filter,
|
|
571
|
+
owner: groupedParams.owner,
|
|
572
|
+
catalogID: groupedParams.catalogID ?? params.catalogID,
|
|
573
|
+
count: groupedParams.count,
|
|
574
|
+
aggregates: groupedParams.aggregates,
|
|
575
|
+
groupBy: groupedParams.groupBy,
|
|
576
|
+
having: groupedParams.having,
|
|
577
|
+
orderBy: groupedParams.orderBy,
|
|
578
|
+
limit: groupedParams.limit,
|
|
579
|
+
readAccess: viewerReadAccess
|
|
580
|
+
});
|
|
581
|
+
});
|
|
582
|
+
if (params.complexity?.resolverTimings != null) {
|
|
583
|
+
const elapsed = Math.round((performance.now() - start) * 100) / 100;
|
|
584
|
+
const existing = params.complexity.resolverTimings.get(resolverKey) ?? 0;
|
|
585
|
+
params.complexity.resolverTimings.set(resolverKey, existing + elapsed);
|
|
586
|
+
}
|
|
587
|
+
return result;
|
|
588
|
+
},
|
|
589
|
+
subscribeToDocumentCreated (modelIDs, owner) {
|
|
590
|
+
const baseGenerator = createEventGenerator((event)=>event.type === 'create' && modelIDs.includes(event.document.model) && (owner == null || event.document.owner === owner), (event)=>event.document);
|
|
591
|
+
return withAccessCheck(baseGenerator, (doc)=>doc);
|
|
592
|
+
},
|
|
593
|
+
subscribeToDocumentChanged (id) {
|
|
594
|
+
const baseGenerator = createEventGenerator((event)=>{
|
|
595
|
+
return event.type === 'update' && event.document.id === id && event.document.data !== null;
|
|
596
|
+
}, (event)=>event.document);
|
|
597
|
+
return withAccessCheck(baseGenerator, (doc)=>doc);
|
|
598
|
+
},
|
|
599
|
+
subscribeToDocumentRemoved () {
|
|
600
|
+
return createEventGenerator((event)=>event.type === 'update' && event.document.data === null, (event)=>event.document.id);
|
|
601
|
+
},
|
|
602
|
+
subscribeToDocumentEdgeAdded (modelIDs, hasRelation) {
|
|
603
|
+
const baseGenerator = createEventGenerator((event)=>{
|
|
604
|
+
return event.document.data !== null && modelIDs.includes(event.document.model) && hasRelation(event.document.data) && (event.type === 'create' || event.previous.data !== null && hasRelation(event.previous.data));
|
|
605
|
+
}, (event)=>({
|
|
606
|
+
cursor: event.getCursor(),
|
|
607
|
+
node: event.document
|
|
608
|
+
}));
|
|
609
|
+
return withAccessCheck(baseGenerator, (edge)=>edge.node);
|
|
610
|
+
},
|
|
611
|
+
subscribeToDocumentEdgeRemoved (modelIDs, hasRelation) {
|
|
612
|
+
return createEventGenerator((event)=>{
|
|
613
|
+
return event.type === 'update' && // document model is one of the target models
|
|
614
|
+
modelIDs.includes(event.document.model) && // relation existed in previous version
|
|
615
|
+
event.previous.data !== null && hasRelation(event.previous.data) && // relation is removed
|
|
616
|
+
(event.document.data === null || !hasRelation(event.document.data));
|
|
617
|
+
}, (event)=>event.document.id);
|
|
618
|
+
}
|
|
619
|
+
};
|
|
620
|
+
}
|