@kubun/graphql 0.11.0 → 0.13.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
@@ -105,7 +105,7 @@ function createEnumValueFilterInput(type) {
105
105
  }
106
106
  });
107
107
  }
108
- function createScalarValueFilterInput(type) {
108
+ function createScalarValueFilterInput(type, options = {}) {
109
109
  return new GraphQLInputObjectType({
110
110
  name: `${type.name}ValueFilter`,
111
111
  isOneOf: true,
@@ -136,6 +136,37 @@ function createScalarValueFilterInput(type) {
136
136
  },
137
137
  greaterThanOrEqualTo: {
138
138
  type
139
+ },
140
+ // Substring match only makes sense against string-backed scalars.
141
+ ...options.stringBacked ? {
142
+ contains: {
143
+ type: GraphQLString
144
+ }
145
+ } : {}
146
+ }
147
+ });
148
+ }
149
+ // Opaque identifiers (DID, DocumentID, AttachmentID): equality only, no ordering
150
+ // or substring match — the underlying strings are content-addressed, not text.
151
+ function createExactValueFilterInput(type) {
152
+ return new GraphQLInputObjectType({
153
+ name: `${type.name}ValueFilter`,
154
+ isOneOf: true,
155
+ fields: {
156
+ isNull: {
157
+ type: GraphQLBoolean
158
+ },
159
+ equalTo: {
160
+ type
161
+ },
162
+ notEqualTo: {
163
+ type
164
+ },
165
+ in: {
166
+ type: toList(type)
167
+ },
168
+ notIn: {
169
+ type: toList(type)
139
170
  }
140
171
  }
141
172
  });
@@ -159,6 +190,7 @@ const VALUE_FILTER_INPUTS = {
159
190
  }
160
191
  }
161
192
  }),
193
+ AttachmentIDValueFilter: createExactValueFilterInput(GraphQLAttachmentID),
162
194
  BooleanValueFilter: new GraphQLInputObjectType({
163
195
  name: 'BooleanValueFilter',
164
196
  isOneOf: true,
@@ -171,16 +203,26 @@ const VALUE_FILTER_INPUTS = {
171
203
  }
172
204
  }
173
205
  }),
206
+ DIDValueFilter: createExactValueFilterInput(GraphQLDID),
207
+ DocumentIDValueFilter: createExactValueFilterInput(GraphQLDocID),
174
208
  FloatValueFilter: createScalarValueFilterInput(GraphQLFloat),
175
209
  IntValueFilter: createScalarValueFilterInput(GraphQLInt),
176
- StringValueFilter: createScalarValueFilterInput(GraphQLString)
210
+ StringValueFilter: createScalarValueFilterInput(GraphQLString, {
211
+ stringBacked: true
212
+ })
177
213
  };
214
+ // Keyed by `scalar.name` (the GraphQL type name, e.g. "DID"), not the import
215
+ // binding — keying by binding name silently never matched here before, since
216
+ // scalars are looked up by `scalar.name`. `_owner` bypasses this map entirely
217
+ // (hardcoded to AccountValueFilter), so it is not listed here.
178
218
  const VALUE_FILTER_INPUT_TYPES = {
179
- GraphQLBoolean: 'BooleanValueFilter',
180
- GraphQLDID: 'AccountValueFilter',
181
- GraphQLFloat: 'FloatValueFilter',
182
- GraphQLInt: 'IntValueFilter',
183
- GraphQLString: 'StringValueFilter'
219
+ AttachmentID: 'AttachmentIDValueFilter',
220
+ Boolean: 'BooleanValueFilter',
221
+ DID: 'DIDValueFilter',
222
+ DocumentID: 'DocumentIDValueFilter',
223
+ Float: 'FloatValueFilter',
224
+ Int: 'IntValueFilter',
225
+ String: 'StringValueFilter'
184
226
  };
185
227
  const PatchSetOperation = new GraphQLInputObjectType({
186
228
  name: 'PatchSetOperation',
@@ -247,6 +289,26 @@ const OrderByDirection = new GraphQLEnumType({
247
289
  }
248
290
  }
249
291
  });
292
+ const ArrayPresence = new GraphQLEnumType({
293
+ name: 'ArrayPresence',
294
+ values: {
295
+ NULL: {
296
+ value: 'null'
297
+ },
298
+ NON_NULL: {
299
+ value: 'nonNull'
300
+ },
301
+ EMPTY: {
302
+ value: 'empty'
303
+ },
304
+ NON_EMPTY: {
305
+ value: 'nonEmpty'
306
+ },
307
+ NULL_OR_EMPTY: {
308
+ value: 'nullOrEmpty'
309
+ }
310
+ }
311
+ });
250
312
  const AccessRule = new GraphQLObjectType({
251
313
  name: 'AccessRule',
252
314
  fields: {
@@ -581,6 +643,42 @@ export class SchemaBuilder {
581
643
  }
582
644
  };
583
645
  }
646
+ // Introspection: the viewer's own model-access-default rows. The stored
647
+ // rows are one-per-(model, permissionType); group them by modelID so each
648
+ // result groups read+write into `permissions`, matching `ModelAccessDefaults`.
649
+ queryFields.modelAccessDefaults = {
650
+ type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(ModelAccessDefaults))),
651
+ resolve: async (_root, _args, ctx)=>{
652
+ const viewerDID = ctx.getViewer();
653
+ // The rows are the viewer's OWN defaults and `ownerDID` names the viewer.
654
+ // With no viewer there is no owner to name, and no own rows to return.
655
+ if (viewerDID == null) {
656
+ return [];
657
+ }
658
+ const rows = await ctx.listModelAccessDefaults();
659
+ const byModelID = new Map();
660
+ for (const row of rows){
661
+ let entry = byModelID.get(row.modelID);
662
+ if (entry == null) {
663
+ entry = {
664
+ ownerDID: viewerDID,
665
+ modelID: row.modelID,
666
+ permissions: {}
667
+ };
668
+ byModelID.set(row.modelID, entry);
669
+ }
670
+ entry.permissions[row.permissionType] = {
671
+ level: row.level,
672
+ allowedDIDs: row.allowedDIDs,
673
+ allowedCircles: row.allowedCircles,
674
+ allowedGroups: row.allowedGroups
675
+ };
676
+ }
677
+ return [
678
+ ...byModelID.values()
679
+ ];
680
+ }
681
+ };
584
682
  // Add access control mutations if mutations are enabled
585
683
  if (this._includeMutations) {
586
684
  this._mutations.setModelAccessDefaults = {
@@ -2125,16 +2223,34 @@ export class SchemaBuilder {
2125
2223
  if (schema.type === 'array') {
2126
2224
  const name = this._aliases[id] ?? pascalCase(parentName + schema.title);
2127
2225
  refParentName = name;
2128
- // const itemType = this._buildReferenceFilterInput(schema.items.$ref, schema.title)
2226
+ const itemID = getReferenceID(schema.items.$ref);
2227
+ const itemSchema = this._getReferenceSchema(itemID);
2228
+ // Membership ops need a scalar item type; array-of-object/array-of-array items
2229
+ // fall back to `isNull`-only filtering (no `_buildScalar` support for them).
2230
+ if (itemSchema.type === 'object' || itemSchema.type === 'array') {
2231
+ return new GraphQLInputObjectType({
2232
+ name: `${name}Filter`,
2233
+ isOneOf: true,
2234
+ fields: {
2235
+ isNull: {
2236
+ type: GraphQLBoolean
2237
+ }
2238
+ }
2239
+ });
2240
+ }
2241
+ const itemScalar = this._buildScalar(itemID, itemSchema, name);
2129
2242
  return new GraphQLInputObjectType({
2130
2243
  name: `${name}Filter`,
2131
2244
  isOneOf: true,
2132
2245
  fields: {
2133
- // DB does not support filtering on array values
2134
- // contains: { type: toList(itemType) },
2135
- // isEmpty: { type: GraphQLBoolean },
2136
- isNull: {
2137
- type: GraphQLBoolean
2246
+ includesAll: {
2247
+ type: toList(itemScalar)
2248
+ },
2249
+ includesAny: {
2250
+ type: toList(itemScalar)
2251
+ },
2252
+ presence: {
2253
+ type: ArrayPresence
2138
2254
  }
2139
2255
  }
2140
2256
  });
@@ -2144,7 +2260,9 @@ export class SchemaBuilder {
2144
2260
  if (knownFilter != null) {
2145
2261
  return this._inputObjects[knownFilter];
2146
2262
  }
2147
- const filter = isEnumType(scalar) ? createEnumValueFilterInput(scalar) : createScalarValueFilterInput(scalar);
2263
+ const filter = isEnumType(scalar) ? createEnumValueFilterInput(scalar) : createScalarValueFilterInput(scalar, {
2264
+ stringBacked: true
2265
+ });
2148
2266
  if (this._inputObjects[filter.name] == null) {
2149
2267
  this._inputObjects[filter.name] = filter;
2150
2268
  }
package/package.json CHANGED
@@ -1,50 +1,53 @@
1
1
  {
2
2
  "name": "@kubun/graphql",
3
- "version": "0.11.0",
4
- "license": "see LICENSE.md",
3
+ "version": "0.13.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
- "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"
25
+ "graphql-scalars": "^1.26.0",
26
+ "multiformats": "^14.0.5",
27
+ "@kubun/logger": "^0.12.0",
28
+ "@kubun/hlc": "^0.12.0",
29
+ "@kubun/id": "^0.12.0",
30
+ "@kubun/protocol": "^0.13.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.2",
34
+ "@kokuin/token": "^0.4.0",
35
+ "@kubun/store-graph": "^0.13.0",
36
+ "@kubun/db-better-sqlite": "^0.12.1",
37
+ "@kubun/db": "^0.12.1",
38
+ "@kubun/test-utils": "^0.12.0"
39
+ },
40
+ "publishConfig": {
41
+ "access": "public"
39
42
  },
40
43
  "scripts": {
44
+ "build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
41
45
  "build:clean": "del lib",
42
46
  "build:js": "swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
43
47
  "build:types": "tsc --emitDeclarationOnly --skipLibCheck",
44
48
  "build:types:ci": "tsc --emitDeclarationOnly --declarationMap false",
45
- "build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
49
+ "test": "pnpm run test:types && pnpm run test:unit",
46
50
  "test:types": "tsc --noEmit -p tsconfig.test.json",
47
- "test:unit": "vitest run",
48
- "test": "pnpm run test:types && pnpm run test:unit"
51
+ "test:unit": "vitest run"
49
52
  }
50
53
  }