@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 +3 -15
- package/lib/context.js +2 -6
- package/lib/schema.js +28 -10
- package/lib/search-cursor.d.ts +8 -0
- package/lib/search-cursor.js +38 -0
- package/package.json +3 -3
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(
|
|
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 (
|
|
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
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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(
|
|
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
|
|
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:
|
|
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
|
|
642
|
-
hasPreviousPage:
|
|
659
|
+
hasNextPage,
|
|
660
|
+
hasPreviousPage: offset > 0,
|
|
643
661
|
startCursor: edges.length > 0 ? edges[0].cursor : null,
|
|
644
|
-
endCursor:
|
|
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.
|
|
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.
|
|
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.
|
|
38
|
+
"@kubun/store-graph": "^0.14.1",
|
|
39
39
|
"@kubun/test-utils": "^0.13.0"
|
|
40
40
|
},
|
|
41
41
|
"publishConfig": {
|