@astrale-os/sdk 0.5.0-beta.63 → 0.5.0-beta.65

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.
Files changed (40) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/application/query/collection/identity.d.ts +4 -0
  3. package/dist/application/query/collection/identity.js +15 -0
  4. package/dist/application/query/collection/limits.d.ts +5 -0
  5. package/dist/application/query/collection/limits.js +5 -0
  6. package/dist/application/query/collection/metadata.d.ts +11 -0
  7. package/dist/application/query/collection/metadata.js +62 -0
  8. package/dist/application/query/collection/union.d.ts +19 -0
  9. package/dist/application/query/collection/union.js +157 -0
  10. package/dist/application/query/composite/builder.d.ts +7 -1
  11. package/dist/application/query/composite/builder.js +7 -0
  12. package/dist/application/query/composite/plan.d.ts +16 -1
  13. package/dist/application/query/composite/plan.js +45 -16
  14. package/dist/application/query/composite/query.d.ts +10 -2
  15. package/dist/application/query/define.d.ts +16 -4
  16. package/dist/application/query/define.js +48 -5
  17. package/dist/application/query/execution/execute.d.ts +2 -1
  18. package/dist/application/query/execution/execute.js +22 -5
  19. package/dist/application/query/execution/executor.d.ts +2 -1
  20. package/dist/application/query/execution/executor.js +1 -1
  21. package/dist/application/query/index.d.ts +3 -2
  22. package/dist/application/query/index.js +1 -1
  23. package/dist/application/query/query.d.ts +9 -0
  24. package/dist/index.d.ts +2 -2
  25. package/dist/index.js +1 -1
  26. package/dist/tooling/linter/adapters/typescript/index.d.ts +1 -0
  27. package/dist/tooling/linter/adapters/typescript/index.js +1 -0
  28. package/dist/tooling/linter/adapters/typescript/query-collection-type.d.ts +4 -0
  29. package/dist/tooling/linter/adapters/typescript/query-collection-type.js +94 -0
  30. package/dist/tooling/linter/adapters/typescript/query-observation.js +18 -4
  31. package/dist/tooling/linter/analysis/query/observation.d.ts +1 -1
  32. package/dist/tooling/linter/implementations/source/global.js +2 -0
  33. package/dist/tooling/linter/implementations/source/queries.js +178 -14
  34. package/dist/tooling/linter/implementations/source/shared.d.ts +1 -1
  35. package/dist/tooling/linter/implementations/source/shared.js +6 -1
  36. package/dist/tooling/linter/policy/generated.d.ts +1 -1
  37. package/dist/tooling/linter/policy/generated.js +3 -3
  38. package/dist/tooling/linter/requirements/registry.js +1 -0
  39. package/dist/tooling/packaging/compile.js +11 -7
  40. package/package.json +6 -5
@@ -1,24 +1,27 @@
1
+ import { COLLECTION_QUERY_LIMITS } from '../collection/limits.js';
2
+ import { compileCollectionUnion, partitionCollectionUnion } from '../collection/union.js';
1
3
  import { createQueryComposition, executeQueryPlan } from '../composite/index.js';
2
4
  import { realizeQuery } from '../define.js';
3
5
  import { queryFailure } from '../failure.js';
4
- import { completeSelection } from '../single/index.js';
6
+ import { completeSelection, queryResult } from '../single/index.js';
5
7
  import { executePages } from './pages.js';
6
- export async function executeQuery(client, domain, definition, input) {
8
+ export async function executeQuery(client, domain, query, input) {
7
9
  if (client === null || typeof client !== 'object' || typeof client.query !== 'function') {
8
10
  throw new TypeError('Query execution requires a Client query capability.');
9
11
  }
10
12
  const execute = (ast, options) => client.query(ast, { ...options, expected: domain.closure });
11
- const operation = realizeQuery(definition, domain);
13
+ const operation = realizeQuery(query, domain);
12
14
  try {
13
- if (operation.kind === 'single')
15
+ if (operation.kind === 'single') {
14
16
  return (await runSingle(execute, operation, input));
17
+ }
15
18
  const prepared = operation.prepare?.(input) ?? input;
16
19
  const session = createQueryComposition();
17
20
  const composed = operation.compose(session.builder, prepared);
18
21
  if (isPromiseLike(composed))
19
22
  throw new TypeError('Composite Query composition must be synchronous.');
20
23
  const plan = session.close(composed);
21
- const result = await executeQueryPlan(plan, (child, childInput) => runSingle(execute, realizeSingle(child, domain), childInput));
24
+ const result = await executeQueryPlan(plan, (child, childInput) => runSingle(execute, realizeSingle(child, domain), childInput), (definitions, outputPath) => runCollectionUnion(execute, domain, definitions, outputPath));
22
25
  return (operation.project === undefined ? result : operation.project(result, prepared));
23
26
  }
24
27
  catch (cause) {
@@ -29,6 +32,20 @@ export async function executeQuery(client, domain, definition, input) {
29
32
  throw cause;
30
33
  }
31
34
  }
35
+ async function runCollectionUnion(execute, domain, definitions, outputPath) {
36
+ const compiled = compileCollectionUnion(domain, definitions, outputPath);
37
+ const pages = await executePages(execute, compiled.ast, { size: COLLECTION_QUERY_LIMITS.pageSize }, { maximumPages: COLLECTION_QUERY_LIMITS.maximumPages });
38
+ const context = outputPath.length === 0 ? 'collection union' : outputPath.join('.');
39
+ const values = compiled.kind === 'node'
40
+ ? queryResult.completeNodeValues(pages, context)
41
+ : Object.freeze(pages.flatMap((page) => queryResult.edgeItems(page, context).map((item) => {
42
+ if (item.edge.kind !== 'value') {
43
+ throw new TypeError(`${context} must project Edge values.`);
44
+ }
45
+ return item.edge.value;
46
+ })));
47
+ return partitionCollectionUnion(domain, compiled, values);
48
+ }
32
49
  function realizeSingle(definition, domain) {
33
50
  const operation = realizeQuery(definition, domain);
34
51
  if (operation.kind !== 'single')
@@ -3,7 +3,8 @@ import type { QueryDefinition } from '../query.js';
3
3
  import type { QueryClient } from './execute.js';
4
4
  /** Invocation-bound executor for authored Query definitions. */
5
5
  export interface QueryExecutor<DomainValue extends Domain = Domain> {
6
- <Input, Output>(definition: QueryDefinition<DomainValue, Input, Output>, input: Input): Promise<Output>;
6
+ <Output>(query: QueryDefinition<DomainValue, void, Output>): Promise<Output>;
7
+ <Input, Output>(query: QueryDefinition<DomainValue, Input, Output>, input: Input): Promise<Output>;
7
8
  }
8
9
  type ExecutableDomain<DomainValue extends Domain> = DomainValue extends {
9
10
  readonly source: infer Source extends schema.DomainSchema;
@@ -2,6 +2,6 @@ import { executeQuery } from './execute.js';
2
2
  /** Private Runtime binding to one Client and the exact loaded root Domain. */
3
3
  export function bindQuery(client, domain) {
4
4
  const executable = domain;
5
- const execute = (definition, input) => executeQuery(client, executable, definition, input);
5
+ const execute = (query, input) => executeQuery(client, executable, query, input);
6
6
  return execute;
7
7
  }
@@ -1,7 +1,8 @@
1
- export { defineCompositeQuery, defineQuery, realizeQuery } from './define.js';
1
+ export { defineCollectionQuery, defineCompositeQuery, defineQuery, realizeQuery } from './define.js';
2
+ export type { CollectionQuerySpec } from './collection/metadata.js';
2
3
  export { executeQuery, type QueryClient, type QueryExecutor } from './execution/index.js';
3
4
  export { QueryPaginationError, type QueryFailure, type QueryPaginationErrorCode, } from './failure.js';
4
- export type { CompositeQueryDefinition, QueryAstOf, QueryDefinition, QueryInputOf, QueryOutputOf, SingleQueryDefinition, } from './query.js';
5
+ export type { CollectionQueryDefinition, CollectionValue, CompositeQueryDefinition, QueryAstOf, QueryDefinition, QueryInputOf, QueryOutputOf, SingleQueryDefinition, } from './query.js';
5
6
  export { queryResult } from './single/index.js';
6
7
  export type { CompleteQueryProjectionContext, PaginatedQuery, ProjectedQuery, QueryPages, QueryPagination, QueryProjectionContext, RawQuery, SingleQuery, } from './single/index.js';
7
8
  export type { CompositeQuery, ProjectedCompositeQuery, QueryComposition, QueryPlan, QueryPlanInput, QueryPlanOutput, QueryPlanValue, RawCompositeQuery, } from './composite/index.js';
@@ -1,4 +1,4 @@
1
- export { defineCompositeQuery, defineQuery, realizeQuery } from './define.js';
1
+ export { defineCollectionQuery, defineCompositeQuery, defineQuery, realizeQuery } from './define.js';
2
2
  export { executeQuery } from './execution/index.js';
3
3
  export { QueryPaginationError, } from './failure.js';
4
4
  export { queryResult } from './single/index.js';
@@ -1,10 +1,14 @@
1
1
  import type { QueryAST, QueryResult } from '@astrale-os/kernel-core/graph/query';
2
+ import type { AnyResolvedClass, ResolvedClass } from '@astrale-os/kernel-dsl/v1';
3
+ import type { Edge } from '../../platform/graph/edge/index.js';
4
+ import type { Node } from '../../platform/graph/node/index.js';
2
5
  import type { Domain } from '../../platform/schema/index.js';
3
6
  import type { CompositeQuery } from './composite/index.js';
4
7
  import type { QueryLifecycle, SingleQuery } from './single/index.js';
5
8
  declare const QUERY_DOMAIN: unique symbol;
6
9
  declare const QUERY_INPUT: unique symbol;
7
10
  declare const QUERY_OUTPUT: unique symbol;
11
+ declare const COLLECTION_QUERY_CLASS: unique symbol;
8
12
  /** Inert Domain-bound Query recipe. Its projector is retained privately by the SDK. */
9
13
  export interface QueryDefinition<DomainValue extends Domain = Domain, Input = unknown, Output = QueryResult> {
10
14
  readonly kind: 'query' | 'composite-query';
@@ -15,6 +19,11 @@ export interface QueryDefinition<DomainValue extends Domain = Domain, Input = un
15
19
  export interface SingleQueryDefinition<DomainValue extends Domain = Domain, Input = unknown, Output = QueryResult> extends QueryDefinition<DomainValue, Input, Output> {
16
20
  readonly kind: 'query';
17
21
  }
22
+ export type CollectionValue<Class extends AnyResolvedClass> = Class extends ResolvedClass<'node'> ? Node : Class extends ResolvedClass<'edge'> ? Edge : never;
23
+ /** Closed complete-Class Query recipe admitted for explicit physical union planning. */
24
+ export interface CollectionQueryDefinition<DomainValue extends Domain = Domain, Class extends AnyResolvedClass = AnyResolvedClass> extends SingleQueryDefinition<DomainValue, void, readonly CollectionValue<Class>[]> {
25
+ readonly [COLLECTION_QUERY_CLASS]: Class;
26
+ }
18
27
  export interface CompositeQueryDefinition<DomainValue extends Domain = Domain, Input = unknown, Output = unknown> extends QueryDefinition<DomainValue, Input, Output> {
19
28
  readonly kind: 'composite-query';
20
29
  }
package/dist/index.d.ts CHANGED
@@ -7,8 +7,8 @@ export { defineIntegration, defineProvider } from './application/integration/ind
7
7
  export type { IntegrationClient, IntegrationDefinition, Provider, } from './application/integration/index.js';
8
8
  export { defineMutation, executeMutation } from './application/mutation/index.js';
9
9
  export type { Mutation, MutationFailure } from './application/mutation/index.js';
10
- export { defineCompositeQuery, defineQuery, executeQuery } from './application/query/index.js';
11
- export type { QueryDefinition, QueryFailure } from './application/query/index.js';
10
+ export { defineCollectionQuery, defineCompositeQuery, defineQuery, executeQuery, } from './application/query/index.js';
11
+ export type { CollectionQueryDefinition, QueryDefinition, QueryFailure, } from './application/query/index.js';
12
12
  export { defineRuntime } from './application/runtime/index.js';
13
13
  export type { Runtime } from './application/runtime/index.js';
14
14
  export { stateMachine } from './application/state/index.js';
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ export { defineAction } from './application/action/index.js';
3
3
  export { defineApplication, defineFrontend, external, requirements, route, routes, vite, } from './application/index.js';
4
4
  export { defineIntegration, defineProvider } from './application/integration/index.js';
5
5
  export { defineMutation, executeMutation } from './application/mutation/index.js';
6
- export { defineCompositeQuery, defineQuery, executeQuery } from './application/query/index.js';
6
+ export { defineCollectionQuery, defineCompositeQuery, defineQuery, executeQuery, } from './application/query/index.js';
7
7
  export { defineRuntime } from './application/runtime/index.js';
8
8
  export { stateMachine } from './application/state/index.js';
9
9
  export { defineWorkflow } from './application/workflow/index.js';
@@ -4,6 +4,7 @@ export { collectDependencyIssues, resolveProjectImport } from './imports.js';
4
4
  export { importedSymbol, isLocallyDeclared, resolveImportedSymbol, location, objectProperty, propertyChain, staticText, unwrap, visit, } from './syntax.js';
5
5
  export { assertSingleTypeUniverse } from './type-universe.js';
6
6
  export { observeQueryDefinitions } from './query-observation.js';
7
+ export { queryCollectionKind } from './query-collection-type.js';
7
8
  export { collectSuppressionDirectives } from './suppression-directives.js';
8
9
  import type ts from 'typescript';
9
10
  import type { DomainPolicy } from '../../policy/index.js';
@@ -4,4 +4,5 @@ export { collectDependencyIssues, resolveProjectImport } from './imports.js';
4
4
  export { importedSymbol, isLocallyDeclared, resolveImportedSymbol, location, objectProperty, propertyChain, staticText, unwrap, visit, } from './syntax.js';
5
5
  export { assertSingleTypeUniverse } from './type-universe.js';
6
6
  export { observeQueryDefinitions } from './query-observation.js';
7
+ export { queryCollectionKind } from './query-collection-type.js';
7
8
  export { collectSuppressionDirectives } from './suppression-directives.js';
@@ -0,0 +1,4 @@
1
+ import ts from 'typescript';
2
+ import type { SourceFile, SourceProject } from './index.js';
3
+ /** Infer the closed collection graph kind only when the Domain's real TypeScript program proves it. */
4
+ export declare function queryCollectionKind(project: SourceProject, file: SourceFile, expression: ts.Expression): 'node' | 'edge' | undefined;
@@ -0,0 +1,94 @@
1
+ import { dirname } from 'node:path';
2
+ import ts from 'typescript';
3
+ const contexts = new WeakMap();
4
+ /** Infer the closed collection graph kind only when the Domain's real TypeScript program proves it. */
5
+ export function queryCollectionKind(project, file, expression) {
6
+ const context = typeContext(project);
7
+ if (context === null)
8
+ return undefined;
9
+ const source = context.program.getSourceFile(file.absolutePath);
10
+ if (source === undefined)
11
+ return undefined;
12
+ const node = matchingNode(source, expression.getStart(file.source), expression.end);
13
+ if (node === undefined || !ts.isExpression(node))
14
+ return undefined;
15
+ const kinds = graphKinds(context.checker, context.checker.getTypeAtLocation(node), node);
16
+ return kinds.size === 1 ? [...kinds][0] : undefined;
17
+ }
18
+ function typeContext(project) {
19
+ const existing = contexts.get(project);
20
+ if (existing !== undefined)
21
+ return existing;
22
+ try {
23
+ const configPath = ts.findConfigFile(project.root, ts.sys.fileExists);
24
+ if (configPath === undefined)
25
+ return remember(project, null);
26
+ const loaded = ts.readConfigFile(configPath, ts.sys.readFile);
27
+ if (loaded.error !== undefined)
28
+ return remember(project, null);
29
+ const parsed = ts.parseJsonConfigFileContent(loaded.config, ts.sys, dirname(configPath), { noEmit: true }, configPath);
30
+ const context = {
31
+ program: ts.createProgram({ rootNames: parsed.fileNames, options: parsed.options }),
32
+ };
33
+ return remember(project, {
34
+ program: context.program,
35
+ checker: context.program.getTypeChecker(),
36
+ });
37
+ }
38
+ catch {
39
+ return remember(project, null);
40
+ }
41
+ }
42
+ function remember(project, context) {
43
+ contexts.set(project, context);
44
+ return context;
45
+ }
46
+ function matchingNode(source, start, end) {
47
+ let match;
48
+ const visit = (node) => {
49
+ if (node.getStart(source) > start || node.end < end)
50
+ return;
51
+ if (node.getStart(source) === start && node.end === end)
52
+ match = node;
53
+ node.forEachChild(visit);
54
+ };
55
+ visit(source);
56
+ return match;
57
+ }
58
+ function graphKinds(checker, type, location) {
59
+ const kinds = new Set();
60
+ const inspect = (candidate) => {
61
+ if (candidate.isUnionOrIntersection()) {
62
+ for (const member of candidate.types)
63
+ inspect(member);
64
+ return;
65
+ }
66
+ for (const property of candidate.getProperties()) {
67
+ if (!isCollectionClassBrand(property))
68
+ continue;
69
+ const propertyType = checker.getTypeOfSymbolAtLocation(property, location);
70
+ const kind = propertyType.getProperty('kind');
71
+ if (kind === undefined)
72
+ continue;
73
+ const kindType = checker.getTypeOfSymbolAtLocation(kind, location);
74
+ if (!(kindType.flags & ts.TypeFlags.StringLiteral))
75
+ continue;
76
+ const value = kindType.value;
77
+ if (value === 'node' || value === 'edge')
78
+ kinds.add(value);
79
+ }
80
+ };
81
+ inspect(type);
82
+ return kinds;
83
+ }
84
+ function isCollectionClassBrand(property) {
85
+ return Boolean(property.declarations?.some((declaration) => {
86
+ if (!ts.isPropertySignature(declaration) ||
87
+ !ts.isInterfaceDeclaration(declaration.parent) ||
88
+ declaration.parent.name.text !== 'CollectionQueryDefinition') {
89
+ return false;
90
+ }
91
+ const path = declaration.getSourceFile().fileName.replaceAll('\\', '/');
92
+ return path.includes('/node_modules/@astrale-os/sdk/');
93
+ }));
94
+ }
@@ -22,7 +22,7 @@ function observeFile(file) {
22
22
  return;
23
23
  const origin = resolveImportedSymbol(file, factory.expression);
24
24
  if ((origin.kind !== 'resolved' && origin.kind !== 'ambiguous') ||
25
- origin.name !== 'defineQuery' ||
25
+ (origin.name !== 'defineQuery' && origin.name !== 'defineCollectionQuery') ||
26
26
  (origin.kind === 'resolved' && !SDK_MODULES.has(origin.module))) {
27
27
  return;
28
28
  }
@@ -41,6 +41,18 @@ function observeFile(file) {
41
41
  }));
42
42
  return;
43
43
  }
44
+ if (origin.name === 'defineCollectionQuery') {
45
+ observations.push(Object.freeze({
46
+ subjectId,
47
+ location: sourceLocation,
48
+ constructorIdentity: known(sdkConstructorIdentity(file, factory.expression, origin.name)),
49
+ definitionKind: known('single'),
50
+ canonicalRequestCount: known(1),
51
+ buildCallbackCount: known(1),
52
+ projectCallbackCount: known(0),
53
+ }));
54
+ return;
55
+ }
44
56
  const object = projectedObject(node);
45
57
  if (!object) {
46
58
  const reason = 'definition input is not a static object literal';
@@ -129,12 +141,14 @@ function localConstInitializers(build) {
129
141
  }
130
142
  return declarations;
131
143
  }
132
- function sdkConstructorIdentity(file, expression) {
144
+ function sdkConstructorIdentity(file, expression, name = 'defineQuery') {
133
145
  const spelling = expression.getText(file.source);
134
146
  return Object.freeze({
135
147
  kind: 'sdk-query',
136
- canonicalId: 'astrale.sdk.defineQuery',
137
- aliases: Object.freeze(spelling === 'defineQuery' ? [] : [spelling]),
148
+ canonicalId: name === 'defineCollectionQuery'
149
+ ? 'astrale.sdk.defineCollectionQuery'
150
+ : 'astrale.sdk.defineQuery',
151
+ aliases: Object.freeze(spelling === name ? [] : [spelling]),
138
152
  });
139
153
  }
140
154
  function canonicalRoots(file, node) {
@@ -14,7 +14,7 @@ export type Epistemic<T> = Readonly<{
14
14
  }>;
15
15
  export type QueryConstructorIdentity = Readonly<{
16
16
  kind: 'sdk-query';
17
- canonicalId: 'astrale.sdk.defineQuery';
17
+ canonicalId: 'astrale.sdk.defineQuery' | 'astrale.sdk.defineCollectionQuery';
18
18
  aliases: readonly string[];
19
19
  }> | Readonly<{
20
20
  kind: 'other';
@@ -494,6 +494,7 @@ export const globalRules = [
494
494
  const evidence = [];
495
495
  const owners = new Map();
496
496
  const definitions = [
497
+ { layer: 'queries', name: 'defineCollectionQuery', members: [undefined] },
497
498
  { layer: 'queries', name: 'defineQuery', members: [undefined] },
498
499
  { layer: 'queries', name: 'defineCompositeQuery', members: [undefined] },
499
500
  { layer: 'mutations', name: 'defineMutation', members: [undefined] },
@@ -637,6 +638,7 @@ export const globalRules = [
637
638
  if (file.layer === 'queries' || file.layer === 'mutations') {
638
639
  const definitions = file.layer === 'queries'
639
640
  ? [
641
+ ...definitionObjects(file, 'defineCollectionQuery'),
640
642
  ...definitionObjects(file, 'defineQuery'),
641
643
  ...definitionObjects(file, 'defineCompositeQuery'),
642
644
  ]
@@ -1,7 +1,7 @@
1
1
  import ts from 'typescript';
2
- import { importedSymbol, observeQueryDefinitions, propertyChain, unwrap, visit, } from '../../adapters/typescript/index.js';
2
+ import { importedSymbol, observeQueryDefinitions, propertyChain, queryCollectionKind, unwrap, visit, } from '../../adapters/typescript/index.js';
3
3
  import { decideCanonicalQuery, decideSingleQuery } from '../../analysis/query/index.js';
4
- import { ambiguity, callback, definitionOriginAmbiguity, definitionObjects, domainProjectorEvidence, production, staticId, violation, } from './shared.js';
4
+ import { ambiguity, callback, definitionOriginAmbiguity, definitionObjects, domainProjectorEvidence, propertyExpression, production, returnedExpressions, staticId, violation, } from './shared.js';
5
5
  import { sourceEvidence } from './verifier.js';
6
6
  const FORBIDDEN_COMPOSE_NAMES = new Set(['fetch', 'invoke', 'mutate', 'run', 'step', 'then']);
7
7
  export const queryRules = [
@@ -12,6 +12,7 @@ export const queryRules = [
12
12
  return [
13
13
  ...sourceEvidence(decideCanonicalQuery(observeQueryDefinitions(project))),
14
14
  ...production(project, 'queries').flatMap((file) => [
15
+ ...domainProjectorEvidence(file, 'defineCollectionQuery', 'Collection Query'),
15
16
  ...domainProjectorEvidence(file, 'defineQuery', 'Query'),
16
17
  ...domainProjectorEvidence(file, 'defineCompositeQuery', 'Composite Query'),
17
18
  ]),
@@ -170,7 +171,161 @@ export const queryRules = [
170
171
  return evidence;
171
172
  },
172
173
  },
174
+ {
175
+ id: 'QRY-COLL-FANOUT',
176
+ ruleRevision: '50279cef3618d44cc1c3212a0d72213eca26a78c540f7a2998df244c38fc54ca',
177
+ evaluate(project) {
178
+ const evidence = [];
179
+ for (const file of production(project, 'queries')) {
180
+ for (const definition of definitionObjects(file, 'defineCompositeQuery')) {
181
+ if (definition.origin !== 'resolved' || !definition.object)
182
+ continue;
183
+ const compose = callback(definition.object, 'compose');
184
+ if (!compose)
185
+ continue;
186
+ const builder = composeBuilder(compose);
187
+ if (!builder)
188
+ continue;
189
+ const groups = {
190
+ node: [],
191
+ edge: [],
192
+ };
193
+ let logicalQueries = 0;
194
+ let physicalQueries = 0;
195
+ const returns = returnedExpressions(compose);
196
+ if (returns.length !== 1)
197
+ continue;
198
+ visit(returns[0], (node) => {
199
+ if (!ts.isCallExpression(node))
200
+ return;
201
+ const method = composeBuilderMethod(node.expression, builder);
202
+ if (method === 'query') {
203
+ logicalQueries += 1;
204
+ physicalQueries += 1;
205
+ if (!isVoidCollectionLeaf(node))
206
+ return;
207
+ const selected = node.arguments[1];
208
+ if (selected === undefined)
209
+ return;
210
+ const kind = queryCollectionKind(project, file, selected);
211
+ const member = kind && collectionMember(file, selected, node);
212
+ if (kind && member)
213
+ groups[kind].push(member);
214
+ return;
215
+ }
216
+ if (method !== 'union')
217
+ return;
218
+ const members = staticRecordMembers(node.arguments[0]);
219
+ if (members === undefined)
220
+ return;
221
+ logicalQueries += members;
222
+ physicalQueries += 1;
223
+ });
224
+ const candidates = ['node', 'edge'].filter((kind) => {
225
+ const members = groups[kind];
226
+ return (members.length >= 3 &&
227
+ new Set(members.map(({ id }) => id)).size === members.length &&
228
+ new Set(members.map(({ classKey }) => classKey)).size === members.length);
229
+ });
230
+ if (candidates.length === 0)
231
+ continue;
232
+ const optimizedPhysical = physicalQueries -
233
+ candidates.reduce((total, kind) => total + groups[kind].length, 0) +
234
+ candidates.length;
235
+ const scaffold = candidates
236
+ .map((kind) => {
237
+ const outer = kind === 'node' ? 'nodes' : 'relations';
238
+ const members = groups[kind]
239
+ .map(({ name, value }) => (name === value ? name : `${name}: ${value}`))
240
+ .join(', ');
241
+ return `${outer}: query.union({ ${members} })`;
242
+ })
243
+ .join('\n');
244
+ evidence.push(violation(file, compose, `Compatible collection fan-out has a direct replacement:\n${scaffold}\nPlan estimate: ${logicalQueries} logical → ${optimizedPhysical} physical cursor chains (currently ${physicalQueries} physical).`));
245
+ }
246
+ }
247
+ return evidence;
248
+ },
249
+ },
173
250
  ];
251
+ function isVoidCollectionLeaf(call) {
252
+ const input = call.arguments[2];
253
+ if (input === undefined)
254
+ return true;
255
+ const value = unwrap(input);
256
+ return ((ts.isIdentifier(value) && value.text === 'undefined') ||
257
+ (ts.isVoidExpression(value) && ts.isNumericLiteral(unwrap(value.expression))));
258
+ }
259
+ function collectionMember(file, expression, call) {
260
+ const value = unwrap(expression);
261
+ if (!ts.isIdentifier(value))
262
+ return undefined;
263
+ const outputName = directOutputName(call);
264
+ const semantics = collectionDefinitionSemantics(file, value.text);
265
+ if (outputName === undefined || semantics === undefined)
266
+ return undefined;
267
+ return { name: outputName, value: value.text, ...semantics };
268
+ }
269
+ function directOutputName(call) {
270
+ const property = call.parent;
271
+ if (!ts.isPropertyAssignment(property) || unwrap(property.initializer) !== call)
272
+ return undefined;
273
+ const name = property.name;
274
+ return ts.isIdentifier(name) || ts.isStringLiteral(name) ? name.text : undefined;
275
+ }
276
+ function collectionDefinitionSemantics(file, name) {
277
+ const declarations = [];
278
+ visit(file.source, (node) => {
279
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === name) {
280
+ declarations.push(node);
281
+ }
282
+ });
283
+ if (declarations.length !== 1)
284
+ return undefined;
285
+ const application = declarations[0].initializer && unwrap(declarations[0].initializer);
286
+ if (!application || !ts.isCallExpression(application))
287
+ return undefined;
288
+ const factory = unwrap(application.expression);
289
+ if (!ts.isCallExpression(factory))
290
+ return undefined;
291
+ const origin = importedSymbol(file, factory.expression);
292
+ if (origin?.name !== 'defineCollectionQuery' ||
293
+ (origin.module !== '@astrale-os/sdk' && origin.module !== '@astrale-os/sdk/query')) {
294
+ return undefined;
295
+ }
296
+ const projected = application.arguments[0] && unwrap(application.arguments[0]);
297
+ if (!projected || (!ts.isArrowFunction(projected) && !ts.isFunctionExpression(projected))) {
298
+ return undefined;
299
+ }
300
+ const domain = projected.parameters[0]?.name;
301
+ if (!domain || !ts.isIdentifier(domain))
302
+ return undefined;
303
+ const returns = returnedExpressions(projected);
304
+ if (returns.length !== 1)
305
+ return undefined;
306
+ const object = unwrap(returns[0]);
307
+ if (!ts.isObjectLiteralExpression(object))
308
+ return undefined;
309
+ const id = staticId(propertyExpression(object, 'id'));
310
+ const selected = propertyExpression(object, 'class');
311
+ const chain = selected && propertyChain(selected);
312
+ if (!id || chain?.length !== 3 || chain[0] !== domain.text || chain[1] !== 'classes') {
313
+ return undefined;
314
+ }
315
+ return { id, classKey: chain.slice(1).join('.') };
316
+ }
317
+ function staticRecordMembers(expression) {
318
+ if (!expression)
319
+ return undefined;
320
+ const value = unwrap(expression);
321
+ if (!ts.isObjectLiteralExpression(value))
322
+ return undefined;
323
+ if (value.properties.some((property) => ts.isSpreadAssignment(property) ||
324
+ ('name' in property && property.name && ts.isComputedPropertyName(property.name)))) {
325
+ return undefined;
326
+ }
327
+ return value.properties.length;
328
+ }
174
329
  function composeBuilder(compose) {
175
330
  const name = compose.parameters[0]?.name;
176
331
  if (!name)
@@ -188,20 +343,24 @@ function composeBuilder(compose) {
188
343
  ? element.propertyName.text
189
344
  : undefined
190
345
  : element.name.text;
191
- if (source !== 'query' && source !== 'combine')
346
+ if (source !== 'query' && source !== 'union' && source !== 'combine')
192
347
  continue;
193
348
  if (bindings[source])
194
349
  return undefined;
195
350
  bindings[source] = element.name;
196
351
  }
197
- return bindings.query || bindings.combine ? { owner: compose, ...bindings } : undefined;
352
+ return bindings.query || bindings.union || bindings.combine
353
+ ? { owner: compose, ...bindings }
354
+ : undefined;
198
355
  }
199
356
  function composeBuilderMethod(expression, builder) {
200
357
  const target = unwrap(expression);
201
358
  if (builder.root && ts.isPropertyAccessExpression(target)) {
202
359
  const root = unwrap(target.expression);
203
360
  if (ts.isIdentifier(root) &&
204
- (target.name.text === 'query' || target.name.text === 'combine') &&
361
+ (target.name.text === 'query' ||
362
+ target.name.text === 'union' ||
363
+ target.name.text === 'combine') &&
205
364
  referencesComposeBinding(root, builder.root, builder.owner)) {
206
365
  return target.name.text;
207
366
  }
@@ -210,6 +369,8 @@ function composeBuilderMethod(expression, builder) {
210
369
  return undefined;
211
370
  if (builder.query && referencesComposeBinding(target, builder.query, builder.owner))
212
371
  return 'query';
372
+ if (builder.union && referencesComposeBinding(target, builder.union, builder.owner))
373
+ return 'union';
213
374
  if (builder.combine && referencesComposeBinding(target, builder.combine, builder.owner)) {
214
375
  return 'combine';
215
376
  }
@@ -320,10 +481,10 @@ function composeReturnEvidence(file, compose, builder) {
320
481
  if (!compose.body)
321
482
  return [];
322
483
  if (!ts.isBlock(compose.body)) {
323
- return isBuilderCombineCall(compose.body, builder)
484
+ return isBuilderClosedPlanCall(compose.body, builder)
324
485
  ? []
325
486
  : [
326
- violation(file, compose.body, 'Composite Query compose does not return the closed plan from combine.'),
487
+ violation(file, compose.body, 'Composite Query compose does not return the closed plan from combine or union.'),
327
488
  ];
328
489
  }
329
490
  const evidence = [];
@@ -331,8 +492,8 @@ function composeReturnEvidence(file, compose, builder) {
331
492
  visitOwnBody(compose, (node) => {
332
493
  if (ts.isReturnStatement(node)) {
333
494
  returns += 1;
334
- if (!node.expression || !isBuilderCombineCall(node.expression, builder)) {
335
- evidence.push(violation(file, node, 'Composite Query compose return does not produce the closed plan from combine.'));
495
+ if (!node.expression || !isBuilderClosedPlanCall(node.expression, builder)) {
496
+ evidence.push(violation(file, node, 'Composite Query compose return does not produce the closed plan from combine or union.'));
336
497
  }
337
498
  }
338
499
  else if (ts.isThrowStatement(node)) {
@@ -344,9 +505,12 @@ function composeReturnEvidence(file, compose, builder) {
344
505
  }
345
506
  return evidence;
346
507
  }
347
- function isBuilderCombineCall(expression, builder) {
508
+ function isBuilderClosedPlanCall(expression, builder) {
348
509
  const value = unwrap(expression);
349
- return ts.isCallExpression(value) && composeBuilderMethod(value.expression, builder) === 'combine';
510
+ if (!ts.isCallExpression(value))
511
+ return false;
512
+ const method = composeBuilderMethod(value.expression, builder);
513
+ return method === 'combine' || method === 'union';
350
514
  }
351
515
  function composePlanReachabilityEvidence(file, compose, builder) {
352
516
  const authored = new Set();
@@ -360,7 +524,7 @@ function composePlanReachabilityEvidence(file, compose, builder) {
360
524
  const roots = [];
361
525
  if (!ts.isBlock(compose.body)) {
362
526
  const root = unwrap(compose.body);
363
- if (ts.isCallExpression(root) && composeBuilderMethod(root.expression, builder) === 'combine') {
527
+ if (ts.isCallExpression(root) && isBuilderClosedPlanCall(root, builder)) {
364
528
  roots.push(root);
365
529
  }
366
530
  }
@@ -369,8 +533,7 @@ function composePlanReachabilityEvidence(file, compose, builder) {
369
533
  if (!ts.isReturnStatement(node) || !node.expression)
370
534
  return;
371
535
  const root = unwrap(node.expression);
372
- if (ts.isCallExpression(root) &&
373
- composeBuilderMethod(root.expression, builder) === 'combine') {
536
+ if (ts.isCallExpression(root) && isBuilderClosedPlanCall(root, builder)) {
374
537
  roots.push(root);
375
538
  }
376
539
  });
@@ -937,6 +1100,7 @@ function writeTargetReferencesBuilder(node, builder) {
937
1100
  function isComposeBuilderReference(reference, builder) {
938
1101
  return Boolean((builder.root && referencesComposeBinding(reference, builder.root, builder.owner)) ||
939
1102
  (builder.query && referencesComposeBinding(reference, builder.query, builder.owner)) ||
1103
+ (builder.union && referencesComposeBinding(reference, builder.union, builder.owner)) ||
940
1104
  (builder.combine && referencesComposeBinding(reference, builder.combine, builder.owner)));
941
1105
  }
942
1106
  function statementShadowsComposeBinding(statement, name, scope, owner) {
@@ -21,7 +21,7 @@ export declare function memberCalls(file: SourceFile, importedName: string, memb
21
21
  export declare function definitionObjects(file: SourceFile, importedName: string, member?: string, modules?: Set<string>, argumentIndex?: number): DefinitionObject[];
22
22
  export declare function definitionOriginAmbiguity(file: SourceFile, definition: DefinitionObject, name: string): SourceRuleEvidence | undefined;
23
23
  /** Prove the clean curried Domain projector shape for Query and Mutation recipes. */
24
- export declare function domainProjectorEvidence(file: SourceFile, importedName: 'defineCompositeQuery' | 'defineMutation' | 'defineQuery', label: string): SourceRuleEvidence[];
24
+ export declare function domainProjectorEvidence(file: SourceFile, importedName: 'defineCollectionQuery' | 'defineCompositeQuery' | 'defineMutation' | 'defineQuery', label: string): SourceRuleEvidence[];
25
25
  export declare function violation(file: SourceFile, node: ts.Node, evidence: string): SourceRuleEvidence;
26
26
  export declare function ambiguity(file: SourceFile, node: ts.Node, evidence: string): SourceRuleEvidence;
27
27
  export declare function localTarget(project: SourceProject, file: SourceFile, sourceImport: SourceImport): SourceFile | undefined;
@@ -15,7 +15,12 @@ export const DSL_MODULES = new Set([
15
15
  '@astrale-os/kernel-dsl/v1',
16
16
  '@astrale-os/kernel-core',
17
17
  ]);
18
- const DOMAIN_BOUND_DEFINITIONS = new Set(['defineCompositeQuery', 'defineMutation', 'defineQuery']);
18
+ const DOMAIN_BOUND_DEFINITIONS = new Set([
19
+ 'defineCollectionQuery',
20
+ 'defineCompositeQuery',
21
+ 'defineMutation',
22
+ 'defineQuery',
23
+ ]);
19
24
  export function production(project, layer) {
20
25
  return project.files.filter((file) => file.role === 'production' && (layer === undefined || file.layer === layer));
21
26
  }
@@ -1,4 +1,4 @@
1
1
  import type { DomainPolicySource } from './index.js';
2
- /** Generated from the validated Domain knowledge catalog. Do not edit by hand. */
2
+ /** Generated from the validated Domain knowledge catalog. Regenerate with pnpm sync:linter-policy. */
3
3
  export declare const domainPolicyDigest: string;
4
4
  export declare const domainPolicySource: DomainPolicySource;