@simtlix/simfinity-core 3.11.0 → 3.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/types/index.d.ts CHANGED
@@ -2,6 +2,8 @@ import type {
2
2
  GraphQLEnumType,
3
3
  GraphQLError,
4
4
  GraphQLInputObjectType,
5
+ GraphQLList,
6
+ GraphQLNonNull,
5
7
  GraphQLObjectType,
6
8
  GraphQLOutputType,
7
9
  GraphQLScalarType,
@@ -13,6 +15,20 @@ export const QLOperator: GraphQLEnumType;
13
15
  export const QLSort: GraphQLInputObjectType;
14
16
  export const QLValue: GraphQLScalarType<unknown, unknown>;
15
17
 
18
+ /**
19
+ * The scalar of the `version` field that Simfinity adds to a type that declares optimistic
20
+ * concurrency (see {@link ConcurrencyDeclaration}). It holds safe integers from 1 to
21
+ * {@link MAX_RECORD_VERSION}, a JSON number on the wire: a string, a float, 0, a negative number and an
22
+ * unsafe integer fail GraphQL coercion. A client sends the version it read back unchanged in updates
23
+ * and deletes, and Simfinity advances it. Only the managed field may use it in a type, which
24
+ * `createSchema()` and `describeModels()` enforce with `INVALID_MODEL` (400); a registered mutation or
25
+ * custom query may use it in its own input and output types. Import it from the facade the application
26
+ * uses: the protection depends on one copy of core per process.
27
+ */
28
+ export const RecordVersion: GraphQLScalarType;
29
+ /** The highest version, `Number.MAX_SAFE_INTEGER`. The version of a record never goes past it. */
30
+ export const MAX_RECORD_VERSION: number;
31
+
16
32
  /**
17
33
  * A `QLOperator` value. The operators of 3.7.0 are `EQ`, `NE`, `LT`, `LTE`, `GT`, `GTE`, `BTW`, `IN`,
18
34
  * `NIN` and `LIKE`. The literal text operators take a non-null string without U+0000 on a `String`
@@ -42,6 +58,26 @@ export type QueryOperator =
42
58
  | 'ILIKE' | 'STARTS_WITH' | 'ISTARTS_WITH' | 'ENDS_WITH' | 'IENDS_WITH' | 'IEQ'
43
59
  | 'IS_EMPTY' | 'IS_NOT_EMPTY';
44
60
 
61
+ /**
62
+ * A condition of a {@link FilterGroup}, as the `QLFilterCondition` input takes it: the field it
63
+ * tests, its {@link QueryOperator} (`EQ` when omitted) and its value. On an object field, `path`
64
+ * names the member it tests; `IS_EMPTY` and `IS_NOT_EMPTY` take no value and may omit `path` to test
65
+ * the field itself.
66
+ */
67
+ export interface FilterCondition {
68
+ field: string;
69
+ operator?: QueryOperator | (string & {});
70
+ value?: any;
71
+ path?: string;
72
+ }
73
+
74
+ /** A `QLFilterGroup` value: flat conditions plus nested `AND` and `OR` groups. */
75
+ export interface FilterGroup {
76
+ AND?: FilterGroup[];
77
+ OR?: FilterGroup[];
78
+ conditions?: FilterCondition[];
79
+ }
80
+
45
81
  /**
46
82
  * The query language features a {@link DatabaseAdapter} compiles. Declare it so that the runtime
47
83
  * sends the adapter only conditions it compiles; later releases add members for later features.
@@ -77,7 +113,8 @@ export interface FieldDescription {
77
113
  itemRequired: boolean;
78
114
  unique: boolean;
79
115
  readOnly: boolean;
80
- scalar?: 'ID' | 'String' | 'Int' | 'Float' | 'Boolean' | 'DateTime' | 'Enum';
116
+ /** `'Version'` is the managed version field of a type that declares optimistic concurrency (`version: true`). */
117
+ scalar?: 'ID' | 'String' | 'Int' | 'Float' | 'Boolean' | 'DateTime' | 'Enum' | 'Version';
81
118
  values?: string[];
82
119
  enumValues?: { name: string; value: unknown }[];
83
120
  target?: string;
@@ -85,6 +122,12 @@ export interface FieldDescription {
85
122
  fields?: FieldDescription[];
86
123
  private?: boolean;
87
124
  inferred?: boolean;
125
+ /**
126
+ * Set on the managed version field of a type that declares optimistic concurrency, which
127
+ * `describeModels()` appends after the type's own fields: `{ name: 'version', scalar: 'Version',
128
+ * required: true, readOnly: true, version: true }`.
129
+ */
130
+ version?: true;
88
131
  }
89
132
  export interface ModelDescription {
90
133
  entities: Array<{
@@ -99,6 +142,13 @@ export interface ModelDescription {
99
142
  * fields are left out of every entity and embedded type, and the types only they reach are not
100
143
  * described. Throws `INVALID_MODEL` (400) for an invalid `extensions.virtual` declaration, as
101
144
  * `createSchema()` does (see {@link VirtualFieldOptions}).
145
+ *
146
+ * A type that declares optimistic concurrency (see {@link ConcurrencyDeclaration}) is described with
147
+ * its version field last, before `createSchema()` has added it and after, and the call throws
148
+ * `INVALID_MODEL` (400) for an invalid declaration, for a field named `version` that Simfinity did not
149
+ * add, for a reference stored under `version` (`connectionField: 'version'`), for a collection of
150
+ * such items that links through `version`, for such a type used as an embedded value, and for any
151
+ * other field typed {@link RecordVersion}.
102
152
  */
103
153
  export function describeModels(registrations: ModelRegistration[]): ModelDescription;
104
154
  /**
@@ -290,6 +340,19 @@ export type ScopeFunction<Model = any, Session = any> = (params: ScopeParams<Mod
290
340
  * was changed.
291
341
  */
292
342
  export type TypeScopes<Model = any, Session = any> = Partial<Record<ScopeOperation, ScopeFunction<Model, Session>>>;
343
+ /**
344
+ * Value of a type's `extensions.concurrency`: the entity is protected by optimistic concurrency.
345
+ * Simfinity adds a read-only `version: RecordVersion!` field to the type, which starts at 1 and grows
346
+ * by 1 with each accepted write; a client sends the version it read back in updates and deletes, and a
347
+ * write at another version fails with `VERSION_CONFLICT` (409). The only valid value is
348
+ * `{ mode: 'optimistic' }`: `null`, an array, another mode or an extra key throws `INVALID_MODEL`
349
+ * (400). So does a type that is not an entity, one used as an embedded value, one that already has a
350
+ * field named `version` (or a field stored under that name), and an adapter that does not declare
351
+ * `writeFeatures.optimisticConcurrency`.
352
+ */
353
+ export interface ConcurrencyDeclaration {
354
+ mode: 'optimistic';
355
+ }
293
356
 
294
357
  /**
295
358
  * Process-wide limits for generated add, update and state-action mutations. Any other option,
@@ -327,6 +390,42 @@ export interface QueryLimitsOptions {
327
390
  maxReferenceFrames?: number;
328
391
  }
329
392
 
393
+ /**
394
+ * The type of a view parameter: a value the client sends as a variable or a literal, so a scalar,
395
+ * an enum or a list of them, each possibly non-null, never an input object.
396
+ */
397
+ export type ViewParamType =
398
+ | GraphQLScalarType
399
+ | GraphQLEnumType
400
+ | GraphQLList<ViewParamType>
401
+ | GraphQLNonNull<GraphQLScalarType | GraphQLEnumType | GraphQLList<ViewParamType>>;
402
+
403
+ /** A view parameter: an argument of the view's root field that only its `where` reads. */
404
+ export interface ViewParam {
405
+ type: ViewParamType;
406
+ description?: string;
407
+ defaultValue?: unknown;
408
+ }
409
+
410
+ /** The declaration of a named view (see {@link Runtime.registerView}). */
411
+ export interface ViewOptions {
412
+ /** The connected endpoint type whose list the view returns. */
413
+ type: GraphQLObjectType;
414
+ /** Non-empty; the root field's description. */
415
+ description: string;
416
+ /**
417
+ * Arguments of the root field besides the generated list arguments, named unlike them and unlike
418
+ * the fields of `type`. They never reach the adapter: only `where` receives them.
419
+ */
420
+ params?: Record<string, ViewParam>;
421
+ /**
422
+ * Called on every read with the param values and the request context. The groups it returns are
423
+ * appended to the client's filters after middleware, so neither the client nor middleware can drop
424
+ * them; their paths may name fields that are not queryable.
425
+ */
426
+ where: (input: { params: Record<string, any>; context: any }) => FilterGroup[] | Promise<FilterGroup[]>;
427
+ }
428
+
330
429
  export interface Runtime<Model = any, Session = any> {
331
430
  /** The same process-wide setting as the root `configureQueryLimits`. */
332
431
  configureQueryLimits(options?: QueryLimitsOptions): void;
@@ -410,6 +509,17 @@ export interface Runtime<Model = any, Session = any> {
410
509
  * (500) for a name already registered.
411
510
  */
412
511
  registerCustomQuery(name: string, description: string, input: GraphQLInputObjectType | null | undefined, output: GraphQLOutputType, callback: (input: any, context: any) => any): void;
512
+ /**
513
+ * Register a named view: a root query field that returns the list of `options.type` with the
514
+ * generated list arguments plus `options.params`, and appends the groups of `options.where` after
515
+ * middleware. A schema includes the view exactly when it includes the type. Throws `INVALID_VIEW`
516
+ * (500) for an invalid declaration and `QUERY_NAME_CONFLICT` (500) for a name already registered as
517
+ * a view, keeping the first registration. `createSchema` throws `INVALID_VIEW` when the type is not
518
+ * connected in this runtime or a param is named like one of its fields or a generated list
519
+ * argument, and `QUERY_NAME_CONFLICT` when an included view is named like an included generated or
520
+ * custom query.
521
+ */
522
+ registerView(name: string, options: ViewOptions): void;
413
523
  /** Owns the full workflow transaction unless an active caller session is supplied. */
414
524
  saveObject(typeName: string, args: Record<string, any>, session?: Session, context?: any): Promise<any>;
415
525
  preventCreatingCollection(prevent: boolean): void;
@@ -432,6 +542,15 @@ export interface DatabaseAdapter<Model = any, Session = any> {
432
542
  * collections also needs `referenceEmptiness: true`, and fails with `UNSUPPORTED_QUERY` without it.
433
543
  */
434
544
  queryFeatures?: QueryFeatures | null | undefined;
545
+ /**
546
+ * Optional. Declares the write features this adapter implements. `createRuntime` reads it once and
547
+ * throws `INVALID_WRITE_FEATURES` (500) unless it is undefined, null or an object whose
548
+ * `optimisticConcurrency`, if present, is a boolean. A schema with a type that declares
549
+ * `extensions.concurrency` (see {@link ConcurrencyDeclaration}) throws `INVALID_MODEL` (400) unless
550
+ * the adapter declares `optimisticConcurrency: true`, which commits it to the `options.expectedVersion`
551
+ * contract of `update` and `delete` and to the requirements on `find` below.
552
+ */
553
+ writeFeatures?: { optimisticConcurrency?: boolean } | null | undefined;
435
554
  bind?(runtime: Pick<Runtime<Model, Session>, 'getModel' | 'getType' | 'getRegistrations'>): void;
436
555
  validateRegistration?(registration: RuntimeRegistration<Model, Session>): void;
437
556
  /**
@@ -440,10 +559,16 @@ export interface DatabaseAdapter<Model = any, Session = any> {
440
559
  * must never be stored: an adapter that builds storage from a type's fields skips each field for
441
560
  * which {@link isVirtualField} returns true, on the types its embedded fields reach as well, or
442
561
  * builds it from `describeModels(registrations)`, which leaves them out. Generated writes, filters,
443
- * sorts and aggregations never name a virtual field; a controller hook can still assign one.
562
+ * sorts and aggregations never name a virtual field; a controller hook can still assign one. The
563
+ * version field of a type that declares optimistic concurrency is injected after `prepare()`, so a
564
+ * type here may not have it yet; `describeModels(registrations)` describes it either way.
444
565
  */
445
566
  prepare?(registrations: RuntimeRegistration<Model, Session>[], options: { createCollection: boolean }): void;
446
- /** Creates the model of a registered type. Its fields include virtual fields; see `prepare()`. */
567
+ /**
568
+ * Creates the model of a registered type. Its fields include virtual fields; see `prepare()`. A type
569
+ * that declares optimistic concurrency already has its injected version field here, which
570
+ * `describeModels(registrations)` describes as well.
571
+ */
447
572
  createModel(type: GraphQLObjectType, callback: ((model: Model) => void) | null | undefined, options: { createCollection: boolean }): Model;
448
573
  /**
449
574
  * Casts a reference or batch key to the stored identifier type. Throw a `SimfinityError` with
@@ -505,8 +630,31 @@ export interface DatabaseAdapter<Model = any, Session = any> {
505
630
  */
506
631
  readByIdIsInert?(model: Model): boolean;
507
632
  prepareUpdate(set: Record<string, any>, unset: Record<string, string>): any;
508
- update(model: Model, id: any, changes: any, session?: Session): any;
509
- delete(model: Model, id: any, session?: Session): any;
633
+ /**
634
+ * With `options.expectedVersion`, one atomic write that matches the ID and a stored version equal to
635
+ * it (on MongoDB a missing or null stored version counts as 1), applies `changes`, sets the version
636
+ * to `expectedVersion + 1` and returns the updated record. When nothing matches, or when
637
+ * `expectedVersion >= MAX_RECORD_VERSION` and the write is skipped, it reads the record by ID in the
638
+ * same session: it returns null for a missing one, throws `VERSION_CONFLICT` (409) when the stored
639
+ * version differs, `VERSION_EXHAUSTED` (409) when stored and expected are `MAX_RECORD_VERSION`, and
640
+ * returns null otherwise. Without `options.expectedVersion` it behaves as before. A wrapper must
641
+ * forward every argument, `options` included.
642
+ */
643
+ update(model: Model, id: any, changes: any, session?: Session, options?: { expectedVersion?: number }): any;
644
+ /**
645
+ * With `options.expectedVersion`, one atomic delete that matches the ID and the version, and returns
646
+ * the deleted record. When nothing matches, it decides as `update` does, without the exhausted case:
647
+ * a delete does not advance the version, so a record at `MAX_RECORD_VERSION` is deleted like any
648
+ * other. Without it, it behaves as before. A wrapper must forward every argument, `options`
649
+ * included.
650
+ */
651
+ delete(model: Model, id: any, session?: Session, options?: { expectedVersion?: number }): any;
652
+ /**
653
+ * An adapter that declares `writeFeatures.optimisticConcurrency` must run `find` inside the given
654
+ * `session`, honor `options.requiredId` and accept `options.context`: the runtime reads through
655
+ * `find` with the caller's session to check, before a hook runs, whether the caller can read a
656
+ * record whose version is stale.
657
+ */
510
658
  find(model: Model, type: GraphQLObjectType, args: any, session?: Session | null, options?: { requiredId?: any; context?: any }): any;
511
659
  count(model: Model, type: GraphQLObjectType, args: any, session?: Session | null): Promise<number> | number;
512
660
  aggregate(model: Model, type: GraphQLObjectType, args: any, session?: Session | null): any;