@kubun/graphql 0.11.0 → 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.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 { ViewerReadAccess } from '@kubun/store-graph';
3
+ import type { 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';
@@ -83,6 +83,7 @@ export type ReadContextDB = {
83
83
  modelID: string;
84
84
  rank: number;
85
85
  }>>;
86
+ listUserModelAccessDefaults(ownerDID: string): Promise<Array<StoredAccessDefaultRow>>;
86
87
  };
87
88
  /**
88
89
  * Query scope carried alongside a connection value so child resolvers (e.g.
@@ -148,6 +149,12 @@ export type QueryContext = {
148
149
  modelID: string;
149
150
  rank: number;
150
151
  }>>;
152
+ /**
153
+ * The viewer's own stored model-access-default rows, across every model and
154
+ * permission type. Scoped to the current viewer DID - not for cross-member
155
+ * visibility.
156
+ */
157
+ listModelAccessDefaults(): Promise<Array<StoredAccessDefaultRow>>;
151
158
  };
152
159
  /**
153
160
  * Subscription functions requiring server implementation
package/lib/context.js CHANGED
@@ -8,6 +8,7 @@ import { withSpan } from '@sozai/otel';
8
8
  * generator. When a consumer falls behind, the oldest events are evicted so a
9
9
  * single slow subscriber cannot grow memory without bound.
10
10
  */ const DEFAULT_SUBSCRIPTION_QUEUE_SIZE = 1000;
11
+ /** Access checks in flight at once — see `filterByAccess`. */ const ACCESS_CHECK_CONCURRENCY = 25;
11
12
  /**
12
13
  * Minimum spacing between repeated overflow warnings for a single generator, so
13
14
  * a sustained overflow logs at most once per interval instead of per drop.
@@ -25,6 +26,34 @@ import { withSpan } from '@sozai/otel';
25
26
  const logger = getKubunLogger('graphql');
26
27
  export function createReadContext(params) {
27
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
+ }
28
57
  function traced(name, attributes, fn) {
29
58
  if (params.tracer != null) {
30
59
  return withSpan(params.tracer, name, {
@@ -206,6 +235,9 @@ export function createReadContext(params) {
206
235
  first
207
236
  });
208
237
  },
238
+ async listModelAccessDefaults () {
239
+ return await store.listUserModelAccessDefaults(viewerDID);
240
+ },
209
241
  async resolveConnection (modelIDs, args) {
210
242
  const resolverKey = `connection:${modelIDs.join(',')}`;
211
243
  const start = params.complexity?.resolverTimings != null ? performance.now() : 0;
@@ -252,17 +284,7 @@ export function createReadContext(params) {
252
284
  // Apply access control
253
285
  let accessible = documents;
254
286
  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
- }
287
+ accessible = await filterByAccess(documents, accessChecker);
266
288
  }
267
289
  // Order by search rank
268
290
  const rankMap = new Map(searchResults.map((r)=>[
@@ -491,17 +513,7 @@ export function createReadContext(params) {
491
513
  return documents;
492
514
  }
493
515
  // 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
- }
516
+ const accessible = await filterByAccess(documents, accessChecker);
505
517
  if (params.complexity != null) {
506
518
  params.complexity.resolverCount++;
507
519
  for (const id of modelIDs){
package/lib/scalars.js CHANGED
@@ -6,6 +6,7 @@ function validateURL(input) {
6
6
  try {
7
7
  return new URL(input).toString();
8
8
  } catch {
9
+ // biome-ignore lint/style/useErrorCause: GraphQLError
9
10
  throw new GraphQLError(`Invalid URL: ${input}`);
10
11
  }
11
12
  }
package/lib/schema.js CHANGED
@@ -581,6 +581,42 @@ export class SchemaBuilder {
581
581
  }
582
582
  };
583
583
  }
584
+ // Introspection: the viewer's own model-access-default rows. The stored
585
+ // rows are one-per-(model, permissionType); group them by modelID so each
586
+ // result groups read+write into `permissions`, matching `ModelAccessDefaults`.
587
+ queryFields.modelAccessDefaults = {
588
+ type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(ModelAccessDefaults))),
589
+ resolve: async (_root, _args, ctx)=>{
590
+ const viewerDID = ctx.getViewer();
591
+ // The rows are the viewer's OWN defaults and `ownerDID` names the viewer.
592
+ // With no viewer there is no owner to name, and no own rows to return.
593
+ if (viewerDID == null) {
594
+ return [];
595
+ }
596
+ const rows = await ctx.listModelAccessDefaults();
597
+ const byModelID = new Map();
598
+ for (const row of rows){
599
+ let entry = byModelID.get(row.modelID);
600
+ if (entry == null) {
601
+ entry = {
602
+ ownerDID: viewerDID,
603
+ modelID: row.modelID,
604
+ permissions: {}
605
+ };
606
+ byModelID.set(row.modelID, entry);
607
+ }
608
+ entry.permissions[row.permissionType] = {
609
+ level: row.level,
610
+ allowedDIDs: row.allowedDIDs,
611
+ allowedCircles: row.allowedCircles,
612
+ allowedGroups: row.allowedGroups
613
+ };
614
+ }
615
+ return [
616
+ ...byModelID.values()
617
+ ];
618
+ }
619
+ };
584
620
  // Add access control mutations if mutations are enabled
585
621
  if (this._includeMutations) {
586
622
  this._mutations.setModelAccessDefaults = {
package/package.json CHANGED
@@ -1,50 +1,50 @@
1
1
  {
2
2
  "name": "@kubun/graphql",
3
- "version": "0.11.0",
4
- "license": "see LICENSE.md",
3
+ "version": "0.12.0",
5
4
  "keywords": [],
5
+ "license": "see LICENSE.md",
6
+ "sideEffects": false,
6
7
  "type": "module",
7
- "main": "lib/index.js",
8
- "types": "lib/index.d.ts",
9
8
  "exports": {
10
9
  ".": "./lib/index.js"
11
10
  },
11
+ "main": "lib/index.js",
12
+ "types": "lib/index.d.ts",
12
13
  "files": [
13
14
  "lib/*",
14
15
  "LICENSE.md"
15
16
  ],
16
- "sideEffects": false,
17
17
  "dependencies": {
18
- "@sozai/async": "^0.1.0",
19
- "@sozai/codec": "^0.1.0",
20
- "@sozai/event": "^0.1.0",
21
- "@sozai/otel": "^0.2.0",
18
+ "@sozai/async": "^0.2.1",
19
+ "@sozai/codec": "^0.4.0",
20
+ "@sozai/event": "^0.1.3",
21
+ "@sozai/otel": "^0.3.2",
22
22
  "change-case": "^5.4.4",
23
23
  "graphql": "^16.14.2",
24
24
  "graphql-relay": "^0.10.2",
25
25
  "graphql-scalars": "^1.25.0",
26
- "multiformats": "^14.0.4",
27
- "@kubun/hlc": "^0.11.0",
28
- "@kubun/id": "^0.11.0",
29
- "@kubun/logger": "^0.11.0",
30
- "@kubun/protocol": "^0.11.0"
26
+ "multiformats": "^14.0.5",
27
+ "@kubun/id": "^0.12.0",
28
+ "@kubun/logger": "^0.12.0",
29
+ "@kubun/protocol": "^0.12.0",
30
+ "@kubun/hlc": "^0.12.0"
31
31
  },
32
32
  "devDependencies": {
33
- "@kokuin/capability": "^0.1.0",
34
- "@kokuin/token": "^0.1.1",
35
- "@kubun/db-better-sqlite": "^0.11.0",
36
- "@kubun/db": "^0.11.0",
37
- "@kubun/store-graph": "^0.11.0",
38
- "@kubun/test-utils": "^0.11.0"
33
+ "@kokuin/capability": "^0.2.1",
34
+ "@kokuin/token": "^0.3.0",
35
+ "@kubun/db": "^0.12.0",
36
+ "@kubun/store-graph": "^0.12.0",
37
+ "@kubun/test-utils": "^0.12.0",
38
+ "@kubun/db-better-sqlite": "^0.12.0"
39
39
  },
40
40
  "scripts": {
41
+ "build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
41
42
  "build:clean": "del lib",
42
43
  "build:js": "swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
43
44
  "build:types": "tsc --emitDeclarationOnly --skipLibCheck",
44
45
  "build:types:ci": "tsc --emitDeclarationOnly --declarationMap false",
45
- "build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
46
+ "test": "pnpm run test:types && pnpm run test:unit",
46
47
  "test:types": "tsc --noEmit -p tsconfig.test.json",
47
- "test:unit": "vitest run",
48
- "test": "pnpm run test:types && pnpm run test:unit"
48
+ "test:unit": "vitest run"
49
49
  }
50
50
  }