@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.js CHANGED
@@ -1 +1,620 @@
1
- import{defer as e}from"@enkaku/async";import{withSpan as t}from"@enkaku/otel";import{DocumentID as o}from"@kubun/id";import{KubunAttributeKeys as r,KubunSpanNames as n}from"@kubun/protocol";export function createReadContext(l){let{store:a,viewerDID:i,accessChecker:s,viewerReadAccess:u}=l;function c(e,o,r){return null!=l.tracer?t(l.tracer,e,{attributes:o},async()=>r()):r()}function m(t,o){let r=!1,n=null,a=[];if(null==l.events)throw Error("Subscriptions require a document event emitter");let i=l.events.on("document:saved",e=>{if(t(e)){let t=o(e);null==n?a.push(t):(n.resolve(t),n=null)}}),s=()=>(i(),r=!0,Promise.resolve({done:!0,value:void 0}));return{[Symbol.asyncDispose]:()=>(i(),r=!0,Promise.resolve()),[Symbol.asyncIterator](){return this},next:()=>{if(r)return Promise.resolve({done:!0,value:void 0});let t=a.shift();return null!=t?Promise.resolve({value:t,done:!1}):(n=e()).promise.then(e=>({value:e,done:!1}))},return:s,throw:s}}return{getViewer:()=>i,async loadDocument(e){let t=`document:${e}`,i=l.complexity?.resolverTimings!=null?performance.now():0,u=await c(n.DATA_QUERY,{[r.DOCUMENT_ID]:e},async()=>{let t=await a.getDocument(o.fromString(e));return t&&null!=t.data?s&&!await s(t,"read")?(null!=l.warnings&&l.warnings.push({message:`Access denied for document ${e}`,code:"KB07"}),null):(null!=l.complexity&&(l.complexity.resolverCount++,l.complexity.documentsScanned++),t):null});if(l.complexity?.resolverTimings!=null){let e=Math.round((performance.now()-i)*100)/100,o=l.complexity.resolverTimings.get(t)??0;l.complexity.resolverTimings.set(t,o+e)}return u},searchDocuments:async(e,t,o)=>await a.searchDocuments({query:e,modelIDs:t,first:o}),async resolveConnection(e,t){let o=`connection:${e.join(",")}`,i=l.complexity?.resolverTimings!=null?performance.now():0,u={modelIDs:e,filter:t.filter,owner:t.owner,catalogID:l.catalogID},m=await c(n.DATA_QUERY,{[r.MODEL_ID]:e.join(",")},async()=>{if(null!=t.search&&""!==t.search.trim()){let o=await a.searchDocuments({query:t.search,modelIDs:e,first:200});if(0===o.length){if(null!=l.complexity)for(let t of(l.complexity.resolverCount++,e))l.complexity.modelsQueried.add(t);return{edges:[],pageInfo:{hasNextPage:!1,hasPreviousPage:!1,startCursor:null,endCursor:null}}}let r=o.map(e=>e.documentID),n=await a.listDocuments({modelIDs:e,docIDs:r}),i=n;if(s)for(let e of(i=[],n))await s(e,"read")?i.push(e):null!=l.warnings&&l.warnings.push({message:`Access denied for document ${e.id}`,code:"KB07"});let u=new Map(o.map(e=>[e.documentID,e.rank]));i.sort((e,t)=>Math.abs(u.get(t.id)??0)-Math.abs(u.get(e.id)??0));let c=i.slice(0,t.first??50);if(null!=l.complexity){for(let t of(l.complexity.resolverCount++,e))l.complexity.modelsQueried.add(t);l.complexity.documentsScanned+=c.length}return{edges:c.map((e,t)=>({cursor:String(t),node:e})),pageInfo:{hasNextPage:i.length>c.length,hasPreviousPage:!1,startCursor:c.length>0?"0":null,endCursor:c.length>0?String(c.length-1):null}}}if(null==s){let{entries:o,hasMore:r}=await a.queryDocuments({...t,modelIDs:e,catalogID:l.catalogID});if(null!=l.complexity){for(let t of(l.complexity.resolverCount++,e))l.complexity.modelsQueried.add(t);l.complexity.documentsScanned+=o.length}return{edges:o.map(e=>({cursor:e.cursor,node:e.document})),pageInfo:{hasNextPage:!!t.first&&r,hasPreviousPage:!t.first&&r,endCursor:o.at(-1)?.cursor??null,startCursor:o.at(0)?.cursor??null}}}let o=[],r=0,n=0;if(null==t.first&&null==t.last){let{entries:n}=await a.queryDocuments({...t,modelIDs:e,catalogID:l.catalogID});for(let e of n)await s(e.document,"read")?o.push(e):null!=l.warnings&&l.warnings.push({message:`Access denied for document ${e.document.id}`,code:"KB07"});r=o.length}else if(null!=t.first){let i=2*(r=t.first),u=t.after;for(;o.length<r&&n<10;){let{entries:c,hasMore:m}=await a.queryDocuments({...t,modelIDs:e,catalogID:l.catalogID,first:i,after:u});if(0===c.length&&!m)break;for(let e of c)if(await s(e.document,"read")){if(o.push(e),o.length>=r)break}else null!=l.warnings&&l.warnings.push({message:`Access denied for document ${e.document.id}`,code:"KB07"});if(!m)break;u=c.at(-1)?.cursor??null,n++}}else if(null!=t.last){let i=2*(r=t.last),u=t.before;for(;o.length<r&&n<10;){let{entries:c,hasMore:m}=await a.queryDocuments({...t,modelIDs:e,catalogID:l.catalogID,last:i,before:u});if(0===c.length&&!m)break;for(let e of[...c].reverse())if(await s(e.document,"read")){if(o.unshift(e),o.length>=r)break}else null!=l.warnings&&l.warnings.push({message:`Access denied for document ${e.document.id}`,code:"KB07"});if(!m)break;u=c.at(0)?.cursor??null,n++}}let i=o.slice(0,r),u=o.length>r;if(null!=l.complexity){for(let t of(l.complexity.resolverCount++,e))l.complexity.modelsQueried.add(t);l.complexity.documentsScanned+=i.length}return{edges:i.map(e=>({cursor:e.cursor,node:e.document})),pageInfo:{hasNextPage:!!t.first&&u,hasPreviousPage:!t.first&&u,endCursor:i.at(-1)?.cursor??null,startCursor:i.at(0)?.cursor??null}}});if(l.complexity?.resolverTimings!=null){let e=Math.round((performance.now()-i)*100)/100,t=l.complexity.resolverTimings.get(o)??0;l.complexity.resolverTimings.set(o,t+e)}return{...m,aggregateScope:u}},async resolveList(e,t){let o=`list:${e.join(",")}`,i=l.complexity?.resolverTimings!=null?performance.now():0,u=await c(n.DATA_QUERY,{[r.MODEL_ID]:e.join(",")},async()=>{let o=await a.listDocuments({modelIDs:e,docIDs:t});if(!s){if(null!=l.complexity){for(let t of(l.complexity.resolverCount++,e))l.complexity.modelsQueried.add(t);l.complexity.documentsScanned+=o.length}return o}let r=[];for(let e of o)await s(e,"read")?r.push(e):null!=l.warnings&&l.warnings.push({message:`Access denied for document ${e.id}`,code:"KB07"});if(null!=l.complexity){for(let t of(l.complexity.resolverCount++,e))l.complexity.modelsQueried.add(t);l.complexity.documentsScanned+=r.length}return r});if(l.complexity?.resolverTimings!=null){let e=Math.round((performance.now()-i)*100)/100,t=l.complexity.resolverTimings.get(o)??0;l.complexity.resolverTimings.set(o,t+e)}return u},async resolveAggregate(e,t){let o=`aggregate:${e.join(",")}`,i=l.complexity?.resolverTimings!=null?performance.now():0,s=await c(n.DATA_QUERY,{[r.MODEL_ID]:e.join(",")},async()=>(null!=l.complexity&&l.complexity.resolverCount++,await a.aggregateDocuments({modelIDs:e,filter:t.filter,owner:t.owner,catalogID:t.catalogID??l.catalogID,count:t.count,aggregates:t.aggregates,readAccess:u})));if(l.complexity?.resolverTimings!=null){let e=Math.round((performance.now()-i)*100)/100,t=l.complexity.resolverTimings.get(o)??0;l.complexity.resolverTimings.set(o,t+e)}return s},async resolveGroupedAggregate(e,t){let o=`grouped-aggregate:${e.join(",")}`,i=l.complexity?.resolverTimings!=null?performance.now():0,s=await c(n.DATA_QUERY,{[r.MODEL_ID]:e.join(",")},async()=>(null!=l.complexity&&l.complexity.resolverCount++,await a.groupedAggregateDocuments({modelIDs:e,filter:t.filter,owner:t.owner,catalogID:t.catalogID??l.catalogID,count:t.count,aggregates:t.aggregates,groupBy:t.groupBy,having:t.having,orderBy:t.orderBy,limit:t.limit,readAccess:u})));if(l.complexity?.resolverTimings!=null){let e=Math.round((performance.now()-i)*100)/100,t=l.complexity.resolverTimings.get(o)??0;l.complexity.resolverTimings.set(o,t+e)}return s},subscribeToDocumentCreated(e,t){let o=m(o=>"create"===o.type&&e.includes(o.document.model)&&(null==t||o.document.owner===t),e=>e.document);return s?async function*(){for await(let e of o)await s(e,"read")&&(yield e)}():o},subscribeToDocumentChanged(e){let t=m(t=>"update"===t.type&&t.document.id===e&&null!==t.document.data,e=>e.document);return s?async function*(){for await(let e of t)await s(e,"read")&&(yield e)}():t},subscribeToDocumentRemoved:()=>m(e=>"update"===e.type&&null===e.document.data,e=>e.document.id),subscribeToDocumentEdgeAdded(e,t){let o=m(o=>null!==o.document.data&&e.includes(o.document.model)&&t(o.document.data)&&("create"===o.type||null!==o.previous.data&&t(o.previous.data)),e=>({cursor:e.getCursor(),node:e.document}));return s?async function*(){for await(let e of o)await s(e.node,"read")&&(yield e)}():o},subscribeToDocumentEdgeRemoved:(e,t)=>m(o=>"update"===o.type&&e.includes(o.document.model)&&null!==o.previous.data&&t(o.previous.data)&&(null===o.document.data||!t(o.document.data)),e=>e.document.id)}}
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
+ }