@simtlix/simfinity-core 3.12.0 → 3.14.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
@@ -12,9 +12,25 @@ import type {
12
12
 
13
13
  /** The filter operator enum; its values are the {@link QueryOperator} names. */
14
14
  export const QLOperator: GraphQLEnumType;
15
+ /** The collection and reference quantifier enum. */
16
+ export const QLQuantifier: GraphQLEnumType;
15
17
  export const QLSort: GraphQLInputObjectType;
16
18
  export const QLValue: GraphQLScalarType<unknown, unknown>;
17
19
 
20
+ /**
21
+ * The scalar of the `version` field that Simfinity adds to a type that declares optimistic
22
+ * concurrency (see {@link ConcurrencyDeclaration}). It holds safe integers from 1 to
23
+ * {@link MAX_RECORD_VERSION}, a JSON number on the wire: a string, a float, 0, a negative number and an
24
+ * unsafe integer fail GraphQL coercion. A client sends the version it read back unchanged in updates
25
+ * and deletes, and Simfinity advances it. Only the managed field may use it in a type, which
26
+ * `createSchema()` and `describeModels()` enforce with `INVALID_MODEL` (400); a registered mutation or
27
+ * custom query may use it in its own input and output types. Import it from the facade the application
28
+ * uses: the protection depends on one copy of core per process.
29
+ */
30
+ export const RecordVersion: GraphQLScalarType;
31
+ /** The highest version, `Number.MAX_SAFE_INTEGER`. The version of a record never goes past it. */
32
+ export const MAX_RECORD_VERSION: number;
33
+
18
34
  /**
19
35
  * A `QLOperator` value. The operators of 3.7.0 are `EQ`, `NE`, `LT`, `LTE`, `GT`, `GTE`, `BTW`, `IN`,
20
36
  * `NIN` and `LIKE`. The literal text operators take a non-null string without U+0000 on a `String`
@@ -31,19 +47,57 @@ export const QLValue: GraphQLScalarType<unknown, unknown>;
31
47
  * embedded object or one whose stored members are all empty by these rules; the record identity is
32
48
  * never empty. The target's `id` right after a reference is empty exactly when the reference is,
33
49
  * including when the target is missing or hidden by its `find` scope. A path that continues inside an
34
- * embedded list or a collection fails with `INVALID_FILTER_PATH`. With an adapter that declares
50
+ * embedded list fails with `INVALID_FILTER_PATH`; shorthand through a collection excludes emptiness
51
+ * on a leaf, so use an explicit group. With an adapter that declares
35
52
  * `queryFeatures.referenceEmptiness: true`, a path may also cross single references and end at a
36
53
  * reference or a collection, and an embedded object may have reference and collection members: a
37
54
  * reference is empty when no record it points to exists that the target's `find` scope admits for
38
55
  * the caller, a collection when no such child exists, and a hidden record on the way reads as
39
56
  * missing; constructs that middleware and scopes add read stored data. With any other adapter they
40
- * fail with `UNSUPPORTED_QUERY`. A term or group condition without a path tests the field itself.
57
+ * fail with `UNSUPPORTED_QUERY`. A term or group condition without a path tests the field itself, except that every term
58
+ * of a quantified relation filter needs a non-empty element-relative path.
41
59
  */
42
60
  export type QueryOperator =
43
61
  | 'EQ' | 'NE' | 'LT' | 'LTE' | 'GT' | 'GTE' | 'BTW' | 'IN' | 'NIN' | 'LIKE'
44
62
  | 'ILIKE' | 'STARTS_WITH' | 'ISTARTS_WITH' | 'ENDS_WITH' | 'IENDS_WITH' | 'IEQ'
45
63
  | 'IS_EMPTY' | 'IS_NOT_EMPTY';
46
64
 
65
+ /** The quantifier applied to the elements visible in a collection or reference frame. */
66
+ export type QueryQuantifier = 'ANY' | 'ALL' | 'NONE';
67
+
68
+ /** A condition inside a filter group, with paths relative to the current frame. */
69
+ export interface QLFilterCondition {
70
+ field: string;
71
+ operator?: QueryOperator | null;
72
+ value?: any;
73
+ path?: string | null;
74
+ }
75
+
76
+ /** Recursive Boolean groups and quantified frames. */
77
+ export interface QLFilterGroup {
78
+ conditions?: Array<QLFilterCondition | null> | null;
79
+ AND?: Array<QLFilterGroup | null> | null;
80
+ OR?: Array<QLFilterGroup | null> | null;
81
+ ANY?: Array<QLQuantifiedGroup | null> | null;
82
+ ALL?: Array<QLQuantifiedGroup | null> | null;
83
+ NONE?: Array<QLQuantifiedGroup | null> | null;
84
+ }
85
+
86
+ /** A frame path ends at a collection or single reference without crossing a list; inner paths are element-relative. */
87
+ export interface QLQuantifiedGroup extends QLFilterGroup {
88
+ path: string;
89
+ }
90
+
91
+ /** A quantified field filter may omit terms; every supplied term needs a non-empty element-relative path. */
92
+ export interface QLTypeFilterExpression {
93
+ quantifier?: QueryQuantifier | null;
94
+ terms?: Array<{
95
+ operator?: QueryOperator | null;
96
+ value?: any;
97
+ path?: string | null;
98
+ } | null> | null;
99
+ }
100
+
47
101
  /**
48
102
  * A condition of a {@link FilterGroup}, as the `QLFilterCondition` input takes it: the field it
49
103
  * tests, its {@link QueryOperator} (`EQ` when omitted) and its value. On an object field, `path`
@@ -57,8 +111,11 @@ export interface FilterCondition {
57
111
  path?: string;
58
112
  }
59
113
 
60
- /** A `QLFilterGroup` value: flat conditions plus nested `AND` and `OR` groups. */
114
+ /** A `QLFilterGroup` value: flat conditions, Boolean groups and element-relative quantified groups. */
61
115
  export interface FilterGroup {
116
+ ANY?: QLQuantifiedGroup[];
117
+ ALL?: QLQuantifiedGroup[];
118
+ NONE?: QLQuantifiedGroup[];
62
119
  AND?: FilterGroup[];
63
120
  OR?: FilterGroup[];
64
121
  conditions?: FilterCondition[];
@@ -83,6 +140,15 @@ export interface QueryFeatures {
83
140
  * must apply each target scope in `scopes`; constructs without a marker read stored data.
84
141
  */
85
142
  referenceEmptiness?: boolean | null | undefined;
143
+ /**
144
+ * Whether the adapter compiles quantified frames and their scoped joins. Requires
145
+ * `referenceEmptiness: true`; undefined or null means false. Invalid declarations fail with
146
+ * `INVALID_QUERY_FEATURES` (500). Client markers have frame-relative scope keys; compilers must
147
+ * apply frame scopes before quantification and honour scoped joins/aggregate anchors. Without
148
+ * this flag explicit quantified forms fail with `UNSUPPORTED_QUERY`; collection filters keep
149
+ * legacy joins. All adapters reject list sorts through collections, including count mode.
150
+ */
151
+ quantifiers?: boolean | null | undefined;
86
152
  }
87
153
 
88
154
  export interface ModelRegistration {
@@ -99,7 +165,8 @@ export interface FieldDescription {
99
165
  itemRequired: boolean;
100
166
  unique: boolean;
101
167
  readOnly: boolean;
102
- scalar?: 'ID' | 'String' | 'Int' | 'Float' | 'Boolean' | 'DateTime' | 'Enum';
168
+ /** `'Version'` is the managed version field of a type that declares optimistic concurrency (`version: true`). */
169
+ scalar?: 'ID' | 'String' | 'Int' | 'Float' | 'Boolean' | 'DateTime' | 'Enum' | 'Version';
103
170
  values?: string[];
104
171
  enumValues?: { name: string; value: unknown }[];
105
172
  target?: string;
@@ -107,6 +174,12 @@ export interface FieldDescription {
107
174
  fields?: FieldDescription[];
108
175
  private?: boolean;
109
176
  inferred?: boolean;
177
+ /**
178
+ * Set on the managed version field of a type that declares optimistic concurrency, which
179
+ * `describeModels()` appends after the type's own fields: `{ name: 'version', scalar: 'Version',
180
+ * required: true, readOnly: true, version: true }`.
181
+ */
182
+ version?: true;
110
183
  }
111
184
  export interface ModelDescription {
112
185
  entities: Array<{
@@ -121,6 +194,13 @@ export interface ModelDescription {
121
194
  * fields are left out of every entity and embedded type, and the types only they reach are not
122
195
  * described. Throws `INVALID_MODEL` (400) for an invalid `extensions.virtual` declaration, as
123
196
  * `createSchema()` does (see {@link VirtualFieldOptions}).
197
+ *
198
+ * A type that declares optimistic concurrency (see {@link ConcurrencyDeclaration}) is described with
199
+ * its version field last, before `createSchema()` has added it and after, and the call throws
200
+ * `INVALID_MODEL` (400) for an invalid declaration, for a field named `version` that Simfinity did not
201
+ * add, for a reference stored under `version` (`connectionField: 'version'`), for a collection of
202
+ * such items that links through `version`, for such a type used as an embedded value, and for any
203
+ * other field typed {@link RecordVersion}.
124
204
  */
125
205
  export function describeModels(registrations: ModelRegistration[]): ModelDescription;
126
206
  /**
@@ -312,6 +392,19 @@ export type ScopeFunction<Model = any, Session = any> = (params: ScopeParams<Mod
312
392
  * was changed.
313
393
  */
314
394
  export type TypeScopes<Model = any, Session = any> = Partial<Record<ScopeOperation, ScopeFunction<Model, Session>>>;
395
+ /**
396
+ * Value of a type's `extensions.concurrency`: the entity is protected by optimistic concurrency.
397
+ * Simfinity adds a read-only `version: RecordVersion!` field to the type, which starts at 1 and grows
398
+ * by 1 with each accepted write; a client sends the version it read back in updates and deletes, and a
399
+ * write at another version fails with `VERSION_CONFLICT` (409). The only valid value is
400
+ * `{ mode: 'optimistic' }`: `null`, an array, another mode or an extra key throws `INVALID_MODEL`
401
+ * (400). So does a type that is not an entity, one used as an embedded value, one that already has a
402
+ * field named `version` (or a field stored under that name), and an adapter that does not declare
403
+ * `writeFeatures.optimisticConcurrency`.
404
+ */
405
+ export interface ConcurrencyDeclaration {
406
+ mode: 'optimistic';
407
+ }
315
408
 
316
409
  /**
317
410
  * Process-wide limits for generated add, update and state-action mutations. Any other option,
@@ -342,11 +435,17 @@ export interface QueryLimitsOptions {
342
435
  /** Positive safe integer; defaults to 1000. Unpaged lists use min(100, maxPageSize). */
343
436
  maxPageSize?: number;
344
437
  /**
345
- * Positive safe integer; defaults to 10. The most distinct scoped joins and frames that the
346
- * client's reference and collection emptiness may need in one list, aggregate or collection-field
347
- * call; more fail with `FILTER_DEPTH_EXCEEDED` (400).
438
+ * Positive safe integer; defaults to 10. The most compiled frames and scoped joins that the
439
+ * client's conditions may need in one list, aggregate or collection-field call; more fail with
440
+ * `FILTER_DEPTH_EXCEEDED` (400), after every client 400 check and before middleware. Units count
441
+ * independently of markers: shorthand per group/prefix, explicit frames separately, scoped joins
442
+ * per containing frame/path/scope chain and emptiness frames per path/shape. Trusted units do
443
+ * not count toward this total. Named views check client arguments and their `where` groups
444
+ * against separate budgets of the same size; the `where` budget runs after middleware.
348
445
  */
349
446
  maxReferenceFrames?: number;
447
+ /** Positive safe integer; defaults to 3. Maximum client/trusted collection or reference frames on one chain. */
448
+ maxFrameNesting?: number;
350
449
  }
351
450
 
352
451
  /**
@@ -501,6 +600,15 @@ export interface DatabaseAdapter<Model = any, Session = any> {
501
600
  * collections also needs `referenceEmptiness: true`, and fails with `UNSUPPORTED_QUERY` without it.
502
601
  */
503
602
  queryFeatures?: QueryFeatures | null | undefined;
603
+ /**
604
+ * Optional. Declares the write features this adapter implements. `createRuntime` reads it once and
605
+ * throws `INVALID_WRITE_FEATURES` (500) unless it is undefined, null or an object whose
606
+ * `optimisticConcurrency`, if present, is a boolean. A schema with a type that declares
607
+ * `extensions.concurrency` (see {@link ConcurrencyDeclaration}) throws `INVALID_MODEL` (400) unless
608
+ * the adapter declares `optimisticConcurrency: true`, which commits it to the `options.expectedVersion`
609
+ * contract of `update` and `delete` and to the requirements on `find` below.
610
+ */
611
+ writeFeatures?: { optimisticConcurrency?: boolean } | null | undefined;
504
612
  bind?(runtime: Pick<Runtime<Model, Session>, 'getModel' | 'getType' | 'getRegistrations'>): void;
505
613
  validateRegistration?(registration: RuntimeRegistration<Model, Session>): void;
506
614
  /**
@@ -509,10 +617,16 @@ export interface DatabaseAdapter<Model = any, Session = any> {
509
617
  * must never be stored: an adapter that builds storage from a type's fields skips each field for
510
618
  * which {@link isVirtualField} returns true, on the types its embedded fields reach as well, or
511
619
  * builds it from `describeModels(registrations)`, which leaves them out. Generated writes, filters,
512
- * sorts and aggregations never name a virtual field; a controller hook can still assign one.
620
+ * sorts and aggregations never name a virtual field; a controller hook can still assign one. The
621
+ * version field of a type that declares optimistic concurrency is injected after `prepare()`, so a
622
+ * type here may not have it yet; `describeModels(registrations)` describes it either way.
513
623
  */
514
624
  prepare?(registrations: RuntimeRegistration<Model, Session>[], options: { createCollection: boolean }): void;
515
- /** Creates the model of a registered type. Its fields include virtual fields; see `prepare()`. */
625
+ /**
626
+ * Creates the model of a registered type. Its fields include virtual fields; see `prepare()`. A type
627
+ * that declares optimistic concurrency already has its injected version field here, which
628
+ * `describeModels(registrations)` describes as well.
629
+ */
516
630
  createModel(type: GraphQLObjectType, callback: ((model: Model) => void) | null | undefined, options: { createCollection: boolean }): Model;
517
631
  /**
518
632
  * Casts a reference or batch key to the stored identifier type. Throw a `SimfinityError` with
@@ -574,8 +688,31 @@ export interface DatabaseAdapter<Model = any, Session = any> {
574
688
  */
575
689
  readByIdIsInert?(model: Model): boolean;
576
690
  prepareUpdate(set: Record<string, any>, unset: Record<string, string>): any;
577
- update(model: Model, id: any, changes: any, session?: Session): any;
578
- delete(model: Model, id: any, session?: Session): any;
691
+ /**
692
+ * With `options.expectedVersion`, one atomic write that matches the ID and a stored version equal to
693
+ * it (on MongoDB a missing or null stored version counts as 1), applies `changes`, sets the version
694
+ * to `expectedVersion + 1` and returns the updated record. When nothing matches, or when
695
+ * `expectedVersion >= MAX_RECORD_VERSION` and the write is skipped, it reads the record by ID in the
696
+ * same session: it returns null for a missing one, throws `VERSION_CONFLICT` (409) when the stored
697
+ * version differs, `VERSION_EXHAUSTED` (409) when stored and expected are `MAX_RECORD_VERSION`, and
698
+ * returns null otherwise. Without `options.expectedVersion` it behaves as before. A wrapper must
699
+ * forward every argument, `options` included.
700
+ */
701
+ update(model: Model, id: any, changes: any, session?: Session, options?: { expectedVersion?: number }): any;
702
+ /**
703
+ * With `options.expectedVersion`, one atomic delete that matches the ID and the version, and returns
704
+ * the deleted record. When nothing matches, it decides as `update` does, without the exhausted case:
705
+ * a delete does not advance the version, so a record at `MAX_RECORD_VERSION` is deleted like any
706
+ * other. Without it, it behaves as before. A wrapper must forward every argument, `options`
707
+ * included.
708
+ */
709
+ delete(model: Model, id: any, session?: Session, options?: { expectedVersion?: number }): any;
710
+ /**
711
+ * An adapter that declares `writeFeatures.optimisticConcurrency` must run `find` inside the given
712
+ * `session`, honor `options.requiredId` and accept `options.context`: the runtime reads through
713
+ * `find` with the caller's session to check, before a hook runs, whether the caller can read a
714
+ * record whose version is stale.
715
+ */
579
716
  find(model: Model, type: GraphQLObjectType, args: any, session?: Session | null, options?: { requiredId?: any; context?: any }): any;
580
717
  count(model: Model, type: GraphQLObjectType, args: any, session?: Session | null): Promise<number> | number;
581
718
  aggregate(model: Model, type: GraphQLObjectType, args: any, session?: Session | null): any;
@@ -618,6 +755,9 @@ export interface DatabaseAdapter<Model = any, Session = any> {
618
755
  }
619
756
  export function createRuntime<Model = any, Session = any>(adapter: DatabaseAdapter<Model, Session>): Runtime<Model, Session>;
620
757
 
758
+ /** Scoped single-reference joins, keyed by a dotted prefix relative to the containing frame. */
759
+ export type QueryJoins = Record<string, QueryPredicate | null>;
760
+
621
761
  /**
622
762
  * `operator` is a {@link QueryOperator}; the value of a text operator is a validated string. For
623
763
  * `IS_EMPTY` and `IS_NOT_EMPTY` the value is null, and `path` may end at an embedded object, an
@@ -626,17 +766,31 @@ export function createRuntime<Model = any, Session = any>(adapter: DatabaseAdapt
626
766
  * references and end at a reference or a collection.
627
767
  *
628
768
  * `scopes` exists only on such an emptiness predicate from a client, and only for a compiler that
629
- * declares `referenceEmptiness: true`. It has one key per dotted path from the queried record: each
769
+ * declares `referenceEmptiness: true`. It has one key per dotted path relative to its frame: each
630
770
  * single reference the path crosses (a scoped join, which admits the target only where the scope
631
771
  * admits it), the end when it is a reference or a collection, and each reference and collection
632
772
  * member of an embedded object (frames, which keep only the records the scope admits). Each value is
633
773
  * the target's `find` scope as a predicate in the target's own frame, or null when the scope added
634
774
  * nothing; after a scoped crossing, a null hop still correlates through that scoped join. Without
635
- * `scopes` the predicate reads stored data, through plain joins.
775
+ * `scopes` the predicate reads stored data, through plain joins. `joins` carries the scopes of
776
+ * single references crossed inside a client frame, with keys relative to the containing frame.
777
+ * A `quantified` node selects elements with `ANY`, `ALL` or `NONE`, applying `scope` before `where`;
778
+ * both predicates use paths relative to the element, and null means no restriction. Empty elements
779
+ * give ANY false, ALL/NONE true; scope is never negated, and frames never multiply roots. `anchor` is
780
+ * an aggregate's legacy join chain; when present, `path` is relative to it and `joins` is absent.
636
781
  */
637
782
  export type QueryPredicate =
638
- | { kind: 'predicate'; path: string[]; operator: string; value: any; scopes?: Record<string, QueryPredicate | null> }
639
- | { kind: 'and' | 'or'; terms: QueryPredicate[] };
783
+ | { kind: 'predicate'; path: string[]; operator: string; value: any; scopes?: Record<string, QueryPredicate | null>; joins?: QueryJoins }
784
+ | { kind: 'and' | 'or'; terms: QueryPredicate[] }
785
+ | {
786
+ kind: 'quantified';
787
+ quantifier: QueryQuantifier;
788
+ path: string[];
789
+ where: QueryPredicate | null;
790
+ scope: QueryPredicate | null;
791
+ joins?: QueryJoins;
792
+ anchor?: string[];
793
+ };
640
794
  export interface QueryPlan {
641
795
  entity: string;
642
796
  where: QueryPredicate | null;
@@ -659,8 +813,8 @@ export interface QueryPlan {
659
813
  * value inside an embedded list or a collection, `INVALID_FILTER_OPERATOR` for an embedded type that
660
814
  * reaches itself through single embedded members, `INVALID_FILTER_VALUE` for a value that is not
661
815
  * omitted or null, and `UNSUPPORTED_QUERY` for a reference or collection on the path or among the
662
- * members. A term without a path (omitted, null or `""`) and a group condition without a path test
663
- * the field itself. For other operators, a filter path of more than one segment that ends at an
816
+ * members. A nonquantified emptiness term without a path (omitted, null or `""`) and a group
817
+ * condition without a path test the field itself; quantified filter terms always need a path. For other operators, a filter path of more than one segment that ends at an
664
818
  * object fails with `INVALID_FILTER_PATH`, as on MongoDB; a bare object field keeps
665
819
  * `MISSING_FILTER_PATH`.
666
820
  *
@@ -669,18 +823,22 @@ export interface QueryPlan {
669
823
  * reads a reference or collection member, fails with `UNSUPPORTED_QUERY` where the order above has
670
824
  * `UNSUPPORTED_QUERY` for references; when it is true, such a condition plans with stored semantics,
671
825
  * unless its term or group condition carries a client scope marker that the runtime filled, which
672
- * becomes the predicate's `scopes`. A marker that is not filled for every path of the condition fails
826
+ * becomes the predicate's `scopes`. `quantifiers` has the same default rule and requires reference
827
+ * emptiness; enabled collection filters emit quantified nodes, with per-group/prefix correlation,
828
+ * element-relative scopes/joins and aggregate anchors beyond legacy J joins. `__scope`, `__implicit`
829
+ * and `__anchor` are reserved internal input metadata. A marker not filled for every path fails
673
830
  * with `DETACHED_CLIENT_FILTER` (500).
674
831
  */
675
- export function createQueryPlan(models: ModelDescription, entityName: string, input?: Record<string, any>, options?: { mode?: QueryPlan['mode']; operators?: Iterable<string>; referenceEmptiness?: boolean }): QueryPlan;
832
+ export function createQueryPlan(models: ModelDescription, entityName: string, input?: Record<string, any>, options?: { mode?: QueryPlan['mode']; operators?: Iterable<string>; referenceEmptiness?: boolean; quantifiers?: boolean }): QueryPlan;
676
833
  export function resolveModelPath(models: ModelDescription, entityName: string, path: string | string[]): FieldDescription[];
677
834
 
678
835
  /**
679
836
  * Process-wide. `maxPageSize` (default 1000) caps page sizes. `maxReferenceFrames` (default 10) caps,
680
- * per list, aggregate or collection-field call, the distinct scoped joins and frames that a client's
681
- * reference and collection emptiness needs; more fail with `FILTER_DEPTH_EXCEEDED` (400). Each must be
682
- * a positive safe integer, or the call throws `INVALID_QUERY_LIMITS` (400) and keeps both limits; an
683
- * omitted option takes its default again.
837
+ * per list, aggregate or collection-field call, the compiled frames and scoped joins a client's
838
+ * conditions need. Named views check client arguments and `where` groups separately. `maxFrameNesting` (default 3) caps nested frames along one chain in client and
839
+ * trusted constructs. Exceeding either frame limit fails with `FILTER_DEPTH_EXCEEDED` (400). Each
840
+ * option must be a positive safe integer, or the call throws `INVALID_QUERY_LIMITS` (400) and keeps
841
+ * every current limit; an omitted option takes its default again.
684
842
  */
685
843
  export function configureQueryLimits(options?: QueryLimitsOptions): void;
686
844
  /**