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