@kubun/graphql 0.14.0 → 0.14.1

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,6 +1,6 @@
1
1
  import { DocumentID } from '@kubun/id';
2
2
  import type { AggregateHaving, AggregateSpec, AggregateValues, DocumentData, DocumentFilter, DocumentNode, DocumentOrderBy, GroupedAggregateOrderTerm, GroupedAggregateValue } from '@kubun/protocol';
3
- import type { StoredAccessDefaultRow, ViewerReadAccess } from '@kubun/store-graph';
3
+ import type { SearchDocumentResult, SearchDocumentsParams, StoredAccessDefaultRow, ViewerReadAccess } from '@kubun/store-graph';
4
4
  import type { Tracer } from '@sozai/otel';
5
5
  import type { GraphQLResolveInfo } from 'graphql';
6
6
  import type { Connection, Edge } from 'graphql-relay';
@@ -74,15 +74,7 @@ export type ReadContextDB = {
74
74
  limit?: number;
75
75
  readAccess?: ViewerReadAccess;
76
76
  }): Promise<Array<GroupedAggregateValue>>;
77
- searchDocuments(params: {
78
- query: string;
79
- modelIDs: Array<string>;
80
- first?: number;
81
- }): Promise<Array<{
82
- documentID: string;
83
- modelID: string;
84
- rank: number;
85
- }>>;
77
+ searchDocuments(params: SearchDocumentsParams): Promise<Array<SearchDocumentResult>>;
86
78
  listUserModelAccessDefaults(ownerDID: string): Promise<Array<StoredAccessDefaultRow>>;
87
79
  };
88
80
  /**
@@ -144,11 +136,7 @@ export type QueryContext = {
144
136
  resolveList(modelIDs: Array<string>, ids: Array<string>): Promise<Array<DocumentNode>>;
145
137
  resolveAggregate(modelIDs: Array<string>, params: ResolveAggregateParams): Promise<AggregateValues>;
146
138
  resolveGroupedAggregate(modelIDs: Array<string>, params: ResolveGroupedAggregateParams): Promise<Array<GroupedAggregateValue>>;
147
- searchDocuments(query: string, modelIDs: Array<string>, first?: number): Promise<Array<{
148
- documentID: string;
149
- modelID: string;
150
- rank: number;
151
- }>>;
139
+ searchDocuments(params: SearchDocumentsParams): Promise<Array<SearchDocumentResult>>;
152
140
  /**
153
141
  * The viewer's own stored model-access-default rows, across every model and
154
142
  * permission type. Scoped to the current viewer DID - not for cross-member
package/lib/context.js CHANGED
@@ -228,12 +228,8 @@ export function createReadContext(params) {
228
228
  }
229
229
  return result;
230
230
  },
231
- async searchDocuments (query, modelIDs, first) {
232
- return await store.searchDocuments({
233
- query,
234
- modelIDs,
235
- first
236
- });
231
+ async searchDocuments (params) {
232
+ return await store.searchDocuments(params);
237
233
  },
238
234
  async listModelAccessDefaults () {
239
235
  return await store.listUserModelAccessDefaults(viewerDID);
package/lib/schema.js CHANGED
@@ -6,6 +6,7 @@ import { connectionArgs, connectionDefinitions, mutationWithClientMutationId, no
6
6
  import { GraphQLDateTimeISO, GraphQLDID, GraphQLDuration, GraphQLEmailAddress, GraphQLJSON, GraphQLJSONObject } from 'graphql-scalars';
7
7
  import { GraphQLAttachmentID, GraphQLCredentialEntryID, GraphQLDocID } from './identifiers.js';
8
8
  import { GraphQLURL } from './scalars.js';
9
+ import { decodeSearchCursor, encodeSearchCursor } from './search-cursor.js';
9
10
  import { getUniqueDocValue, validateAccessIdentifiers } from './utils.js';
10
11
  /**
11
12
  * Create a type-appropriate default value for stubbing extension field resolvers.
@@ -605,19 +606,32 @@ export class SchemaBuilder {
605
606
  }
606
607
  },
607
608
  resolve: async (_root, args, ctx)=>{
608
- const { query, models, first } = args;
609
+ const { query, models, first, after } = args;
609
610
  if (query.trim() === '') {
610
611
  throw new Error('Search query must not be empty');
611
612
  }
613
+ if (first != null && first < 0) {
614
+ throw new Error('Search `first` must not be negative');
615
+ }
616
+ const pageSize = first ?? 20;
617
+ const offset = decodeSearchCursor(after);
612
618
  // Map model names to IDs
613
619
  const modelIDs = models ? models.map((name)=>this._aliasToModelID[name] ?? name) : [
614
620
  ...this._includedModelIDs
615
621
  ];
616
- const results = await ctx.searchDocuments(query, modelIDs, first ?? 20);
617
- // Load documents
618
- const docIDs = results.map((r)=>r.documentID);
622
+ // Over-fetch one hit past the page to detect a following page.
623
+ const hits = await ctx.searchDocuments({
624
+ query,
625
+ modelIDs,
626
+ first: pageSize + 1,
627
+ offset
628
+ });
629
+ const hasNextPage = hits.length > pageSize;
630
+ const page = hasNextPage ? hits.slice(0, pageSize) : hits;
631
+ // Load documents (access control applied inside resolveList)
632
+ const docIDs = page.map((r)=>r.documentID);
619
633
  const allModelIDs = [
620
- ...new Set(results.map((r)=>r.modelID))
634
+ ...new Set(page.map((r)=>r.modelID))
621
635
  ];
622
636
  const documents = docIDs.length > 0 ? await ctx.resolveList(allModelIDs, docIDs) : [];
623
637
  const docMap = new Map(documents.map((d)=>[
@@ -625,23 +639,27 @@ export class SchemaBuilder {
625
639
  d
626
640
  ]));
627
641
  const edges = [];
628
- for (const r of results){
642
+ for(let j = 0; j < page.length; j++){
643
+ const r = page[j];
629
644
  const node = docMap.get(r.documentID);
630
645
  if (node != null) {
646
+ // Cursor = hits consumed through this position, so the next page
647
+ // resumes correctly even when access control drops later docs.
631
648
  edges.push({
632
- cursor: String(edges.length),
649
+ cursor: encodeSearchCursor(offset + j + 1),
633
650
  node,
634
651
  rank: Math.abs(r.rank)
635
652
  });
636
653
  }
637
654
  }
655
+ const endOffset = offset + page.length;
638
656
  return {
639
657
  edges,
640
658
  pageInfo: {
641
- hasNextPage: false,
642
- hasPreviousPage: false,
659
+ hasNextPage,
660
+ hasPreviousPage: offset > 0,
643
661
  startCursor: edges.length > 0 ? edges[0].cursor : null,
644
- endCursor: edges.length > 0 ? edges[edges.length - 1].cursor : null
662
+ endCursor: page.length > 0 ? encodeSearchCursor(endOffset) : null
645
663
  }
646
664
  };
647
665
  }
@@ -0,0 +1,8 @@
1
+ /** Opaque cursor for the top-level `search` query: base64 of `search:<offset>`. */
2
+ export declare function encodeSearchCursor(offset: number): string;
3
+ /**
4
+ * Decode a search cursor to its offset. A null/undefined/empty cursor means
5
+ * "start from the beginning" (offset 0). Anything that is not a cursor this
6
+ * codec produced throws, so a client cannot silently get a wrong page.
7
+ */
8
+ export declare function decodeSearchCursor(cursor: string | null | undefined): number;
@@ -0,0 +1,38 @@
1
+ import { b64uFromUTF, b64uToUTF } from '@sozai/codec';
2
+ const PREFIX = 'search:';
3
+ /** Opaque cursor for the top-level `search` query: base64 of `search:<offset>`. */ export function encodeSearchCursor(offset) {
4
+ return b64uFromUTF(`${PREFIX}${offset}`);
5
+ }
6
+ /**
7
+ * Decode a search cursor to its offset. A null/undefined/empty cursor means
8
+ * "start from the beginning" (offset 0). Anything that is not a cursor this
9
+ * codec produced throws, so a client cannot silently get a wrong page.
10
+ */ export function decodeSearchCursor(cursor) {
11
+ if (cursor == null || cursor === '') {
12
+ return 0;
13
+ }
14
+ let decoded;
15
+ try {
16
+ decoded = b64uToUTF(cursor);
17
+ } catch (e) {
18
+ throw new Error('Invalid search cursor', {
19
+ cause: e
20
+ });
21
+ }
22
+ if (!decoded.startsWith(PREFIX)) {
23
+ throw new Error('Invalid search cursor');
24
+ }
25
+ const raw = decoded.slice(PREFIX.length);
26
+ // Canonical non-negative integer only: no leading zeros, sign, whitespace,
27
+ // or scientific/hex notation — the encoder only ever emits `${offset}`.
28
+ if (!/^(0|[1-9]\d*)$/.test(raw)) {
29
+ throw new Error('Invalid search cursor');
30
+ }
31
+ const offset = Number(raw);
32
+ // Keep `offset + first + 1` (first is bounded by GraphQL's 32-bit Int) within
33
+ // safe-integer range so the downstream LIMIT arithmetic can't lose precision.
34
+ if (offset > Number.MAX_SAFE_INTEGER - 0x80000000) {
35
+ throw new Error('Invalid search cursor');
36
+ }
37
+ return offset;
38
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubun/graphql",
3
- "version": "0.14.0",
3
+ "version": "0.14.1",
4
4
  "keywords": [],
5
5
  "license": "see LICENSE.md",
6
6
  "sideEffects": false,
@@ -18,7 +18,7 @@
18
18
  "@kubun/hlc": "^0.14.0",
19
19
  "@kubun/id": "^0.14.0",
20
20
  "@kubun/logger": "^0.14.0",
21
- "@kubun/protocol": "^0.14.0",
21
+ "@kubun/protocol": "^0.14.1",
22
22
  "@sozai/async": "^0.2.1",
23
23
  "@sozai/codec": "^0.4.0",
24
24
  "@sozai/event": "^0.1.3",
@@ -35,7 +35,7 @@
35
35
  "@kubun/db": "^0.14.0",
36
36
  "@kubun/db-better-sqlite": "^0.14.0",
37
37
  "@kubun/scalars": "^0.14.0",
38
- "@kubun/store-graph": "^0.14.0",
38
+ "@kubun/store-graph": "^0.14.1",
39
39
  "@kubun/test-utils": "^0.13.0"
40
40
  },
41
41
  "publishConfig": {