@simtlix/simfinity-core 3.13.0 → 3.15.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.
@@ -0,0 +1,18 @@
1
+ // Module-local ownership shared by consumers of this exact core installation. The snapshot is
2
+ // registration data only; descriptor formats and HTTP belong to opt-in consumer packages.
3
+ const registrationsBySchema = new WeakMap();
4
+
5
+ export const captureSchemaRegistrations = (schema, registrations, queryable) => {
6
+ const snapshot = registrations.map((entry) => Object.freeze({
7
+ ...entry,
8
+ stateMachine: entry.stateMachine ? Object.freeze({
9
+ initialState: entry.stateMachine.initialState
10
+ ? Object.freeze({ ...entry.stateMachine.initialState }) : null,
11
+ }) : null,
12
+ queryableFields: Object.freeze(Object.fromEntries(Object.values(entry.gqltype.getFields())
13
+ .map((field) => [field.name, queryable(entry.gqltype, field)]))),
14
+ }));
15
+ registrationsBySchema.set(schema, Object.freeze(snapshot));
16
+ };
17
+
18
+ export const schemaRegistrations = (schema) => registrationsBySchema.get(schema);
@@ -0,0 +1,34 @@
1
+ // The transaction owner creates a new queue for every attempt, even when the driver reuses the
2
+ // session. No session is adopted merely because it reports an active transaction.
3
+ export const createTransactionCallbacks = () => {
4
+ const active = new WeakMap();
5
+ const enqueue = (session, run) => {
6
+ const jobs = active.get(session);
7
+ if (!jobs) return false;
8
+ jobs.push(run);
9
+ return true;
10
+ };
11
+ const begin = (session) => {
12
+ const jobs = [];
13
+ let state = 'active';
14
+ const detach = () => { if (active.get(session) === jobs) active.delete(session); };
15
+ active.set(session, jobs);
16
+ return {
17
+ seal() { if (state === 'active') { state = 'sealed'; detach(); } },
18
+ confirm() { if (state === 'sealed') state = 'confirmed'; },
19
+ discard() { if (state !== 'confirmed' && state !== 'drained') { state = 'discarded'; detach(); jobs.length = 0; } },
20
+ async drain() {
21
+ if (state !== 'confirmed') return;
22
+ state = 'drained';
23
+ for (const run of jobs.splice(0)) {
24
+ try { await run(); } catch {
25
+ // Runtime jobs report their own failures. A faulty adapter consumer cannot change an
26
+ // already committed result, stop later jobs, or enter the driver's retry classifier.
27
+ try { console.error('After-commit callback failed'); } catch { /* Logging is best effort. */ }
28
+ }
29
+ }
30
+ },
31
+ };
32
+ };
33
+ return { begin, enqueue };
34
+ };
package/types/index.d.ts CHANGED
@@ -12,6 +12,8 @@ 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
 
@@ -45,19 +47,57 @@ export const MAX_RECORD_VERSION: number;
45
47
  * embedded object or one whose stored members are all empty by these rules; the record identity is
46
48
  * never empty. The target's `id` right after a reference is empty exactly when the reference is,
47
49
  * including when the target is missing or hidden by its `find` scope. A path that continues inside an
48
- * 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
49
52
  * `queryFeatures.referenceEmptiness: true`, a path may also cross single references and end at a
50
53
  * reference or a collection, and an embedded object may have reference and collection members: a
51
54
  * reference is empty when no record it points to exists that the target's `find` scope admits for
52
55
  * the caller, a collection when no such child exists, and a hidden record on the way reads as
53
56
  * missing; constructs that middleware and scopes add read stored data. With any other adapter they
54
- * 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.
55
59
  */
56
60
  export type QueryOperator =
57
61
  | 'EQ' | 'NE' | 'LT' | 'LTE' | 'GT' | 'GTE' | 'BTW' | 'IN' | 'NIN' | 'LIKE'
58
62
  | 'ILIKE' | 'STARTS_WITH' | 'ISTARTS_WITH' | 'ENDS_WITH' | 'IENDS_WITH' | 'IEQ'
59
63
  | 'IS_EMPTY' | 'IS_NOT_EMPTY';
60
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
+
61
101
  /**
62
102
  * A condition of a {@link FilterGroup}, as the `QLFilterCondition` input takes it: the field it
63
103
  * tests, its {@link QueryOperator} (`EQ` when omitted) and its value. On an object field, `path`
@@ -71,8 +111,11 @@ export interface FilterCondition {
71
111
  path?: string;
72
112
  }
73
113
 
74
- /** A `QLFilterGroup` value: flat conditions plus nested `AND` and `OR` groups. */
114
+ /** A `QLFilterGroup` value: flat conditions, Boolean groups and element-relative quantified groups. */
75
115
  export interface FilterGroup {
116
+ ANY?: QLQuantifiedGroup[];
117
+ ALL?: QLQuantifiedGroup[];
118
+ NONE?: QLQuantifiedGroup[];
76
119
  AND?: FilterGroup[];
77
120
  OR?: FilterGroup[];
78
121
  conditions?: FilterCondition[];
@@ -97,6 +140,15 @@ export interface QueryFeatures {
97
140
  * must apply each target scope in `scopes`; constructs without a marker read stored data.
98
141
  */
99
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;
100
152
  }
101
153
 
102
154
  export interface ModelRegistration {
@@ -281,7 +333,27 @@ export class InternalServerError extends SimfinityError {
281
333
  */
282
334
  export function buildErrorFormatter(callback?: (error: SimfinityError) => Error | void): (error: unknown) => GraphQLError;
283
335
 
284
- export interface EntityController<Session = any> {
336
+ export interface AfterCommitErrorDetails {
337
+ committed: true;
338
+ typeName: string;
339
+ operation: 'save' | 'update' | 'delete' | 'state_changed';
340
+ callbackName: 'onSaveCommitted' | 'onUpdateCommitted' | 'onDeleteCommitted' | 'onTransitionCommitted';
341
+ context: any;
342
+ }
343
+ export interface AfterCommitOptions {
344
+ /** Operational reporting only. Rejections are caught and never fail the committed write. */
345
+ onError?: (error: unknown, details: AfterCommitErrorDetails) => void | Promise<void>;
346
+ }
347
+ export interface CommittedControllerCallbacks {
348
+ /** After the owning transaction commits; a detached result and the successful attempt's input. */
349
+ onSaveCommitted?(record: any, args: any, context: any): void | Promise<void>;
350
+ onUpdateCommitted?(record: any, context: any): void | Promise<void>;
351
+ /** Called only when a record was actually deleted. */
352
+ onDeleteCommitted?(record: any, context: any): void | Promise<void>;
353
+ /** Exclusive notification for a state action; action is its registered name. */
354
+ onTransitionCommitted?(record: any, action: string, context: any): void | Promise<void>;
355
+ }
356
+ export interface EntityController<Session = any> extends CommittedControllerCallbacks {
285
357
  onSaving?(record: any, args: any, session: Session | undefined, context: any): void | Promise<void>;
286
358
  onSaved?(record: any, args: any, session: Session | undefined, context: any): void | Promise<void>;
287
359
  onUpdating?(id: any, update: any, session: Session | undefined, context: any): void | Promise<void>;
@@ -383,11 +455,17 @@ export interface QueryLimitsOptions {
383
455
  /** Positive safe integer; defaults to 1000. Unpaged lists use min(100, maxPageSize). */
384
456
  maxPageSize?: number;
385
457
  /**
386
- * Positive safe integer; defaults to 10. The most distinct scoped joins and frames that the
387
- * client's reference and collection emptiness may need in one list, aggregate or collection-field
388
- * call; more fail with `FILTER_DEPTH_EXCEEDED` (400).
458
+ * Positive safe integer; defaults to 10. The most compiled frames and scoped joins that the
459
+ * client's conditions may need in one list, aggregate or collection-field call; more fail with
460
+ * `FILTER_DEPTH_EXCEEDED` (400), after every client 400 check and before middleware. Units count
461
+ * independently of markers: shorthand per group/prefix, explicit frames separately, scoped joins
462
+ * per containing frame/path/scope chain and emptiness frames per path/shape. Trusted units do
463
+ * not count toward this total. Named views check client arguments and their `where` groups
464
+ * against separate budgets of the same size; the `where` budget runs after middleware.
389
465
  */
390
466
  maxReferenceFrames?: number;
467
+ /** Positive safe integer; defaults to 3. Maximum client/trusted collection or reference frames on one chain. */
468
+ maxFrameNesting?: number;
391
469
  }
392
470
 
393
471
  /**
@@ -427,6 +505,8 @@ export interface ViewOptions {
427
505
  }
428
506
 
429
507
  export interface Runtime<Model = any, Session = any> {
508
+ /** Per runtime. No arguments resets to safe default logging; invalid options leave it unchanged. */
509
+ configureAfterCommit(options?: AfterCommitOptions): void;
430
510
  /** The same process-wide setting as the root `configureQueryLimits`. */
431
511
  configureQueryLimits(options?: QueryLimitsOptions): void;
432
512
  /**
@@ -580,6 +660,15 @@ export interface DatabaseAdapter<Model = any, Session = any> {
580
660
  castId(value: any): any;
581
661
  stateValue?(state: { name: string; value: any }): any;
582
662
  withTransaction<T>(session: Session | null | undefined, callback: (session: Session) => Promise<T> | T, model?: Model): Promise<T>;
663
+ /**
664
+ * Optional capability for controller callbacks. Accept only into an active attempt owned by this
665
+ * adapter; false means the supplied session has no owned attempt. A new queue is required per
666
+ * retry, including retries on the same native session. Seal before commit, discard on abort or
667
+ * unresolved uncertainty, and drain once, serially, after confirmed commit and cleanup attempt.
668
+ * Callback failures must never enter transaction retry/rollback paths or fail a committed write.
669
+ * Borrowed operations join the owning attempt; this method does not observe external commits.
670
+ */
671
+ enqueueAfterCommit?(session: Session, run: () => void | Promise<void>): boolean;
583
672
  newRecord(model: Model, data: any, session?: Session): any;
584
673
  saveRecord(model: Model, record: any, session?: Session): any;
585
674
  toObject(record: any): any;
@@ -697,6 +786,9 @@ export interface DatabaseAdapter<Model = any, Session = any> {
697
786
  }
698
787
  export function createRuntime<Model = any, Session = any>(adapter: DatabaseAdapter<Model, Session>): Runtime<Model, Session>;
699
788
 
789
+ /** Scoped single-reference joins, keyed by a dotted prefix relative to the containing frame. */
790
+ export type QueryJoins = Record<string, QueryPredicate | null>;
791
+
700
792
  /**
701
793
  * `operator` is a {@link QueryOperator}; the value of a text operator is a validated string. For
702
794
  * `IS_EMPTY` and `IS_NOT_EMPTY` the value is null, and `path` may end at an embedded object, an
@@ -705,17 +797,31 @@ export function createRuntime<Model = any, Session = any>(adapter: DatabaseAdapt
705
797
  * references and end at a reference or a collection.
706
798
  *
707
799
  * `scopes` exists only on such an emptiness predicate from a client, and only for a compiler that
708
- * declares `referenceEmptiness: true`. It has one key per dotted path from the queried record: each
800
+ * declares `referenceEmptiness: true`. It has one key per dotted path relative to its frame: each
709
801
  * single reference the path crosses (a scoped join, which admits the target only where the scope
710
802
  * admits it), the end when it is a reference or a collection, and each reference and collection
711
803
  * member of an embedded object (frames, which keep only the records the scope admits). Each value is
712
804
  * the target's `find` scope as a predicate in the target's own frame, or null when the scope added
713
805
  * nothing; after a scoped crossing, a null hop still correlates through that scoped join. Without
714
- * `scopes` the predicate reads stored data, through plain joins.
806
+ * `scopes` the predicate reads stored data, through plain joins. `joins` carries the scopes of
807
+ * single references crossed inside a client frame, with keys relative to the containing frame.
808
+ * A `quantified` node selects elements with `ANY`, `ALL` or `NONE`, applying `scope` before `where`;
809
+ * both predicates use paths relative to the element, and null means no restriction. Empty elements
810
+ * give ANY false, ALL/NONE true; scope is never negated, and frames never multiply roots. `anchor` is
811
+ * an aggregate's legacy join chain; when present, `path` is relative to it and `joins` is absent.
715
812
  */
716
813
  export type QueryPredicate =
717
- | { kind: 'predicate'; path: string[]; operator: string; value: any; scopes?: Record<string, QueryPredicate | null> }
718
- | { kind: 'and' | 'or'; terms: QueryPredicate[] };
814
+ | { kind: 'predicate'; path: string[]; operator: string; value: any; scopes?: Record<string, QueryPredicate | null>; joins?: QueryJoins }
815
+ | { kind: 'and' | 'or'; terms: QueryPredicate[] }
816
+ | {
817
+ kind: 'quantified';
818
+ quantifier: QueryQuantifier;
819
+ path: string[];
820
+ where: QueryPredicate | null;
821
+ scope: QueryPredicate | null;
822
+ joins?: QueryJoins;
823
+ anchor?: string[];
824
+ };
719
825
  export interface QueryPlan {
720
826
  entity: string;
721
827
  where: QueryPredicate | null;
@@ -738,8 +844,8 @@ export interface QueryPlan {
738
844
  * value inside an embedded list or a collection, `INVALID_FILTER_OPERATOR` for an embedded type that
739
845
  * reaches itself through single embedded members, `INVALID_FILTER_VALUE` for a value that is not
740
846
  * omitted or null, and `UNSUPPORTED_QUERY` for a reference or collection on the path or among the
741
- * members. A term without a path (omitted, null or `""`) and a group condition without a path test
742
- * the field itself. For other operators, a filter path of more than one segment that ends at an
847
+ * members. A nonquantified emptiness term without a path (omitted, null or `""`) and a group
848
+ * 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
743
849
  * object fails with `INVALID_FILTER_PATH`, as on MongoDB; a bare object field keeps
744
850
  * `MISSING_FILTER_PATH`.
745
851
  *
@@ -748,18 +854,22 @@ export interface QueryPlan {
748
854
  * reads a reference or collection member, fails with `UNSUPPORTED_QUERY` where the order above has
749
855
  * `UNSUPPORTED_QUERY` for references; when it is true, such a condition plans with stored semantics,
750
856
  * unless its term or group condition carries a client scope marker that the runtime filled, which
751
- * becomes the predicate's `scopes`. A marker that is not filled for every path of the condition fails
857
+ * becomes the predicate's `scopes`. `quantifiers` has the same default rule and requires reference
858
+ * emptiness; enabled collection filters emit quantified nodes, with per-group/prefix correlation,
859
+ * element-relative scopes/joins and aggregate anchors beyond legacy J joins. `__scope`, `__implicit`
860
+ * and `__anchor` are reserved internal input metadata. A marker not filled for every path fails
752
861
  * with `DETACHED_CLIENT_FILTER` (500).
753
862
  */
754
- export function createQueryPlan(models: ModelDescription, entityName: string, input?: Record<string, any>, options?: { mode?: QueryPlan['mode']; operators?: Iterable<string>; referenceEmptiness?: boolean }): QueryPlan;
863
+ export function createQueryPlan(models: ModelDescription, entityName: string, input?: Record<string, any>, options?: { mode?: QueryPlan['mode']; operators?: Iterable<string>; referenceEmptiness?: boolean; quantifiers?: boolean }): QueryPlan;
755
864
  export function resolveModelPath(models: ModelDescription, entityName: string, path: string | string[]): FieldDescription[];
756
865
 
757
866
  /**
758
867
  * Process-wide. `maxPageSize` (default 1000) caps page sizes. `maxReferenceFrames` (default 10) caps,
759
- * per list, aggregate or collection-field call, the distinct scoped joins and frames that a client's
760
- * reference and collection emptiness needs; more fail with `FILTER_DEPTH_EXCEEDED` (400). Each must be
761
- * a positive safe integer, or the call throws `INVALID_QUERY_LIMITS` (400) and keeps both limits; an
762
- * omitted option takes its default again.
868
+ * per list, aggregate or collection-field call, the compiled frames and scoped joins a client's
869
+ * conditions need. Named views check client arguments and `where` groups separately. `maxFrameNesting` (default 3) caps nested frames along one chain in client and
870
+ * trusted constructs. Exceeding either frame limit fails with `FILTER_DEPTH_EXCEEDED` (400). Each
871
+ * option must be a positive safe integer, or the call throws `INVALID_QUERY_LIMITS` (400) and keeps
872
+ * every current limit; an omitted option takes its default again.
763
873
  */
764
874
  export function configureQueryLimits(options?: QueryLimitsOptions): void;
765
875
  /**
@@ -1014,3 +1124,6 @@ export const plugins: {
1014
1124
  };
1015
1125
  };
1016
1126
  };
1127
+
1128
+ /** Early opt-out. Import its function from /introspection before loading the runtime. */
1129
+ export interface IntrospectionOptions { fieldExtensions: false; }
@@ -0,0 +1,4 @@
1
+ import type { IntrospectionOptions } from './index.js';
2
+ export type { IntrospectionOptions } from './index.js';
3
+ /** Must run before importing the runtime/facades. Never removes installed metadata. */
4
+ export function configureIntrospection(options: IntrospectionOptions): void;