@kubun/graphql 0.10.0 → 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 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{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?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
+ /**
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
+ }