@vertesia/common 1.5.0-dev.20260717.131047Z → 1.5.0-dev.20260725.083715Z
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/apps.d.ts +184 -48
- package/lib/apps.d.ts.map +1 -1
- package/lib/apps.js +21 -9
- package/lib/apps.js.map +1 -1
- package/lib/audit-trail.d.ts +61 -1
- package/lib/audit-trail.d.ts.map +1 -1
- package/lib/audit-trail.js +15 -0
- package/lib/audit-trail.js.map +1 -1
- package/lib/environment.d.ts +2 -0
- package/lib/environment.d.ts.map +1 -1
- package/lib/environment.js.map +1 -1
- package/lib/index.d.ts +7 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +7 -0
- package/lib/index.js.map +1 -1
- package/lib/interaction.d.ts +77 -1
- package/lib/interaction.d.ts.map +1 -1
- package/lib/interaction.js +48 -0
- package/lib/interaction.js.map +1 -1
- package/lib/platform-event.d.ts +37 -3
- package/lib/platform-event.d.ts.map +1 -1
- package/lib/platform-event.js.map +1 -1
- package/lib/project.d.ts +119 -20
- package/lib/project.d.ts.map +1 -1
- package/lib/project.js +65 -0
- package/lib/project.js.map +1 -1
- package/lib/query.d.ts +6 -0
- package/lib/query.d.ts.map +1 -1
- package/lib/refs.d.ts +1 -0
- package/lib/refs.d.ts.map +1 -1
- package/lib/schema-for-extraction.d.ts +21 -0
- package/lib/schema-for-extraction.d.ts.map +1 -0
- package/lib/schema-for-extraction.js +205 -0
- package/lib/schema-for-extraction.js.map +1 -0
- package/lib/store/agent-run.d.ts +23 -1
- package/lib/store/agent-run.d.ts.map +1 -1
- package/lib/store/conversation-state.d.ts +3 -1
- package/lib/store/conversation-state.d.ts.map +1 -1
- package/lib/store/conversation-state.js.map +1 -1
- package/lib/store/doc-analyzer.d.ts +10 -67
- package/lib/store/doc-analyzer.d.ts.map +1 -1
- package/lib/store/dsl-workflow.d.ts +1 -0
- package/lib/store/dsl-workflow.d.ts.map +1 -1
- package/lib/store/dsl-workflow.js.map +1 -1
- package/lib/store/grounded-extraction.d.ts +146 -0
- package/lib/store/grounded-extraction.d.ts.map +1 -0
- package/lib/store/grounded-extraction.js +8 -0
- package/lib/store/grounded-extraction.js.map +1 -0
- package/lib/store/index.d.ts +1 -0
- package/lib/store/index.d.ts.map +1 -1
- package/lib/store/index.js +1 -0
- package/lib/store/index.js.map +1 -1
- package/lib/store/store.d.ts +314 -1
- package/lib/store/store.d.ts.map +1 -1
- package/lib/store/store.js +451 -0
- package/lib/store/store.js.map +1 -1
- package/lib/store/workflow.d.ts +8 -1
- package/lib/store/workflow.d.ts.map +1 -1
- package/lib/store/workflow.js +5 -0
- package/lib/store/workflow.js.map +1 -1
- package/lib/user.d.ts +14 -0
- package/lib/user.d.ts.map +1 -1
- package/lib/user.js +31 -0
- package/lib/user.js.map +1 -1
- package/lib/vertesia-common.js +2 -2
- package/lib/vertesia-common.js.map +1 -1
- package/lib/view-configuration-validation.d.ts +15 -0
- package/lib/view-configuration-validation.d.ts.map +1 -0
- package/lib/view-configuration-validation.js +63 -0
- package/lib/view-configuration-validation.js.map +1 -0
- package/lib/view-query-validation.d.ts +13 -0
- package/lib/view-query-validation.d.ts.map +1 -0
- package/lib/view-query-validation.js +266 -0
- package/lib/view-query-validation.js.map +1 -0
- package/lib/view-validation-helpers.d.ts +15 -0
- package/lib/view-validation-helpers.d.ts.map +1 -0
- package/lib/view-validation-helpers.js +25 -0
- package/lib/view-validation-helpers.js.map +1 -0
- package/lib/views-schema.d.ts +1992 -0
- package/lib/views-schema.d.ts.map +1 -0
- package/lib/views-schema.js +674 -0
- package/lib/views-schema.js.map +1 -0
- package/lib/views-validation.d.ts +21 -0
- package/lib/views-validation.d.ts.map +1 -0
- package/lib/views-validation.js +164 -0
- package/lib/views-validation.js.map +1 -0
- package/lib/views.d.ts +381 -0
- package/lib/views.d.ts.map +1 -0
- package/lib/views.js +41 -0
- package/lib/views.js.map +1 -0
- package/package.json +5 -5
- package/src/agent-resources.test.ts +100 -0
- package/src/apps.test.ts +9 -1
- package/src/apps.ts +220 -73
- package/src/audit-trail.ts +83 -0
- package/src/environment.ts +2 -0
- package/src/index.ts +12 -0
- package/src/interaction.ts +135 -1
- package/src/platform-event.ts +39 -2
- package/src/project.test.ts +44 -0
- package/src/project.ts +205 -22
- package/src/query.ts +6 -0
- package/src/refs.ts +1 -0
- package/src/schema-for-extraction.test.ts +191 -0
- package/src/schema-for-extraction.ts +231 -0
- package/src/store/agent-run.ts +29 -0
- package/src/store/content-type-editing.test.ts +17 -0
- package/src/store/conversation-state.ts +4 -1
- package/src/store/doc-analyzer.ts +10 -76
- package/src/store/dsl-workflow.ts +1 -0
- package/src/store/grounded-extraction.ts +154 -0
- package/src/store/index.ts +1 -0
- package/src/store/store.ts +800 -1
- package/src/store/workflow.ts +12 -0
- package/src/user.ts +46 -0
- package/src/view-configuration-validation.ts +74 -0
- package/src/view-query-validation.test.ts +21 -0
- package/src/view-query-validation.ts +319 -0
- package/src/view-validation-helpers.ts +28 -0
- package/src/views-schema.test.ts +364 -0
- package/src/views-schema.ts +689 -0
- package/src/views-validation.ts +234 -0
- package/src/views.ts +484 -0
package/src/store/workflow.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import type { ExecutionTokenUsage, HttpTimeoutOptions, ModelOptions } from '@llumiverse/common';
|
|
2
2
|
import type {
|
|
3
|
+
AgentResourceReference,
|
|
3
4
|
ConversationVisibility,
|
|
4
5
|
InteractionExecutionConfiguration,
|
|
5
6
|
InteractionRef,
|
|
6
7
|
UserChannel,
|
|
7
8
|
} from '../interaction.js';
|
|
9
|
+
import { normalizeAgentResources } from '../interaction.js';
|
|
8
10
|
import type { JSONObject, JSONValue } from '../json.js';
|
|
9
11
|
import type { JSONSchema } from '../json-schema.js';
|
|
12
|
+
import type { SupportedEmbeddingTypes } from '../project.js';
|
|
10
13
|
import type { AgentToolApprovalMode } from './agent-approval.js';
|
|
11
14
|
import type { WorkflowInput } from './dsl-workflow.js';
|
|
12
15
|
|
|
@@ -769,6 +772,8 @@ export interface AgentMessageDetails extends Record<string, unknown> {
|
|
|
769
772
|
outputFiles?: string[];
|
|
770
773
|
files?: ConversationFile[] | string[];
|
|
771
774
|
plan?: PlanTask[];
|
|
775
|
+
/** Deep-linkable references to resources a tool created/updated/deleted (see AgentResourceReference). */
|
|
776
|
+
resources?: AgentResourceReference[];
|
|
772
777
|
streaming_id?: string;
|
|
773
778
|
streaming_id_scope?: 'workflow_run' | 'workstream';
|
|
774
779
|
chunk_index?: number;
|
|
@@ -845,6 +850,11 @@ export function isToolCallMessage(msg: AgentMessage): msg is AgentMessage & { de
|
|
|
845
850
|
);
|
|
846
851
|
}
|
|
847
852
|
|
|
853
|
+
/** Extract the normalized resource references carried on a message's details, if any. */
|
|
854
|
+
export function getResourcesFromMessage(msg: AgentMessage): AgentResourceReference[] {
|
|
855
|
+
return normalizeAgentResources((msg.details as AgentMessageDetails | undefined)?.resources);
|
|
856
|
+
}
|
|
857
|
+
|
|
848
858
|
export function isDocumentEventMessage(msg: AgentMessage): msg is AgentMessage & { details: DocumentEventDetails } {
|
|
849
859
|
const details = msg.details as Record<string, unknown> | undefined;
|
|
850
860
|
return (
|
|
@@ -1391,6 +1401,8 @@ export interface AgentIntakeWorkflowResult {
|
|
|
1391
1401
|
collectionIds?: string[];
|
|
1392
1402
|
/** Whether embeddings were generated */
|
|
1393
1403
|
hasEmbeddings: boolean;
|
|
1404
|
+
/** Embedding kinds that were actually generated. Skipped or failed activity results are omitted. */
|
|
1405
|
+
generatedEmbeddings?: SupportedEmbeddingTypes[];
|
|
1394
1406
|
}
|
|
1395
1407
|
|
|
1396
1408
|
// ---------------------------------------------------------------------------
|
package/src/user.ts
CHANGED
|
@@ -116,10 +116,54 @@ export function quotaTierForAccountType(accountType: AccountType | undefined | n
|
|
|
116
116
|
}
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
export const ACCOUNT_NAMESPACE_MAX_LENGTH = 63;
|
|
120
|
+
export const ACCOUNT_APP_ACCESS_MESSAGE_MAX_LENGTH = 1000;
|
|
121
|
+
export const RESERVED_ACCOUNT_NAMESPACES = [
|
|
122
|
+
'admin',
|
|
123
|
+
'api',
|
|
124
|
+
'apps',
|
|
125
|
+
'auth',
|
|
126
|
+
'cdn',
|
|
127
|
+
'docs',
|
|
128
|
+
'internal-auth',
|
|
129
|
+
'mcp',
|
|
130
|
+
'status',
|
|
131
|
+
'sts',
|
|
132
|
+
'www',
|
|
133
|
+
] as const;
|
|
134
|
+
|
|
135
|
+
const ACCOUNT_NAMESPACE_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
136
|
+
|
|
137
|
+
export function normalizeAccountNamespace(namespace: string): string {
|
|
138
|
+
return namespace.trim().toLowerCase();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function getAccountNamespaceValidationError(namespace: string): string | undefined {
|
|
142
|
+
if (!namespace) {
|
|
143
|
+
return 'Account namespace is required';
|
|
144
|
+
}
|
|
145
|
+
if (namespace.length > ACCOUNT_NAMESPACE_MAX_LENGTH || !ACCOUNT_NAMESPACE_PATTERN.test(namespace)) {
|
|
146
|
+
return 'Account namespace must be a lowercase DNS label containing only letters, numbers, and hyphens';
|
|
147
|
+
}
|
|
148
|
+
if ((RESERVED_ACCOUNT_NAMESPACES as readonly string[]).includes(namespace)) {
|
|
149
|
+
return `Account namespace '${namespace}' is reserved`;
|
|
150
|
+
}
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
|
|
119
154
|
export interface Account {
|
|
120
155
|
id: string;
|
|
121
156
|
name: string;
|
|
122
157
|
|
|
158
|
+
/** Public DNS-label-compatible identifier used by organization app domains. */
|
|
159
|
+
namespace?: string;
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Plain-text instructions shown when a signed-in user has no application access.
|
|
163
|
+
* @maxLength 1000
|
|
164
|
+
*/
|
|
165
|
+
app_access_message?: string;
|
|
166
|
+
|
|
123
167
|
email_domains: string[];
|
|
124
168
|
|
|
125
169
|
onboarding: {
|
|
@@ -144,6 +188,8 @@ export interface Account {
|
|
|
144
188
|
|
|
145
189
|
export interface UpdateAccountPayload {
|
|
146
190
|
name?: string;
|
|
191
|
+
/** @maxLength 1000 */
|
|
192
|
+
app_access_message?: string;
|
|
147
193
|
email_domains?: string[];
|
|
148
194
|
billing?: AccountBilling;
|
|
149
195
|
quota_tier?: QuotaTier;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import Ajv, { type ErrorObject } from 'ajv';
|
|
2
|
+
import type { ViewExperienceConfiguration } from './views.js';
|
|
3
|
+
import {
|
|
4
|
+
PersistedViewExperienceConfigurationJsonSchema,
|
|
5
|
+
ViewExperienceConfigurationJsonSchema,
|
|
6
|
+
} from './views-schema.js';
|
|
7
|
+
import { type ViewValidationIssue, validateViewExperienceSemantics } from './views-validation.js';
|
|
8
|
+
|
|
9
|
+
export type ViewConfigurationValidationMode = 'draft' | 'persisted';
|
|
10
|
+
|
|
11
|
+
const ajv = new Ajv.default({ allErrors: true, strict: false });
|
|
12
|
+
const validateDraftStructure = ajv.compile(ViewExperienceConfigurationJsonSchema);
|
|
13
|
+
const validatePersistedStructure = ajv.compile(PersistedViewExperienceConfigurationJsonSchema);
|
|
14
|
+
|
|
15
|
+
function jsonPointerPath(instancePath: string): string {
|
|
16
|
+
if (instancePath === '') return 'configuration';
|
|
17
|
+
return instancePath
|
|
18
|
+
.slice(1)
|
|
19
|
+
.split('/')
|
|
20
|
+
.map((segment) => segment.replaceAll('~1', '/').replaceAll('~0', '~'))
|
|
21
|
+
.reduce((path, segment) => {
|
|
22
|
+
if (/^\d+$/.test(segment)) return `${path}[${segment}]`;
|
|
23
|
+
return path.length === 0 ? segment : `${path}.${segment}`;
|
|
24
|
+
}, '');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function errorPath(error: ErrorObject): string {
|
|
28
|
+
const base = jsonPointerPath(error.instancePath);
|
|
29
|
+
if (error.keyword === 'required' && 'missingProperty' in error.params) {
|
|
30
|
+
const missingProperty = String(error.params.missingProperty);
|
|
31
|
+
return base === 'configuration' ? missingProperty : `${base}.${missingProperty}`;
|
|
32
|
+
}
|
|
33
|
+
if (error.keyword === 'additionalProperties' && 'additionalProperty' in error.params) {
|
|
34
|
+
const additionalProperty = String(error.params.additionalProperty);
|
|
35
|
+
return base === 'configuration' ? additionalProperty : `${base}.${additionalProperty}`;
|
|
36
|
+
}
|
|
37
|
+
return base;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function structuralIssues(value: unknown, mode: ViewConfigurationValidationMode): ViewValidationIssue[] {
|
|
41
|
+
const validateStructure = mode === 'persisted' ? validatePersistedStructure : validateDraftStructure;
|
|
42
|
+
if (validateStructure(value)) return [];
|
|
43
|
+
const issues = (validateStructure.errors ?? []).map((error: ErrorObject) => ({
|
|
44
|
+
path: errorPath(error),
|
|
45
|
+
message: error.message ?? 'is invalid',
|
|
46
|
+
}));
|
|
47
|
+
return [...new Map(issues.map((issue) => [`${issue.path}\u0000${issue.message}`, issue])).values()];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Validate both the canonical JSON structure and semantic cross-reference rules.
|
|
52
|
+
*
|
|
53
|
+
* Persisted mode adds the documentation requirement structurally. Blank
|
|
54
|
+
* descriptions pass the shape check and are rejected by semantic validation,
|
|
55
|
+
* while structure-first short-circuiting prevents duplicate errors.
|
|
56
|
+
*/
|
|
57
|
+
export function validateViewConfiguration(
|
|
58
|
+
value: unknown,
|
|
59
|
+
mode: ViewConfigurationValidationMode = 'draft',
|
|
60
|
+
): ViewValidationIssue[] {
|
|
61
|
+
const issues = structuralIssues(value, mode);
|
|
62
|
+
if (issues.length > 0) return issues;
|
|
63
|
+
return validateViewExperienceSemantics(value as ViewExperienceConfiguration, mode);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** @deprecated Use validateViewConfiguration(value, 'draft'). */
|
|
67
|
+
export function validateViewExperienceConfiguration(value: unknown): ViewValidationIssue[] {
|
|
68
|
+
return validateViewConfiguration(value, 'draft');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** @deprecated Use validateViewConfiguration(value, 'persisted'). */
|
|
72
|
+
export function validatePersistedViewExperienceConfiguration(value: unknown): ViewValidationIssue[] {
|
|
73
|
+
return validateViewConfiguration(value, 'persisted');
|
|
74
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { validateSupportedViewQuery } from './view-query-validation.js';
|
|
3
|
+
|
|
4
|
+
function deeplyNestedFilter(depth: number): unknown {
|
|
5
|
+
let query: unknown = { term: { status: 'active' } };
|
|
6
|
+
for (let index = 0; index < depth; index++) {
|
|
7
|
+
query = { bool: { filter: query } };
|
|
8
|
+
}
|
|
9
|
+
return query;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe('View query validation', () => {
|
|
13
|
+
it('rejects deeply nested queries without overflowing the call stack', () => {
|
|
14
|
+
expect(() => validateSupportedViewQuery(deeplyNestedFilter(6_000))).not.toThrow();
|
|
15
|
+
expect(validateSupportedViewQuery(deeplyNestedFilter(6_000))).toContainEqual(
|
|
16
|
+
expect.objectContaining({
|
|
17
|
+
message: 'exceeds the maximum of 100 query clauses',
|
|
18
|
+
}),
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ELASTICSEARCH_FIELD_PATH_PATTERN as FIELD_PATTERN,
|
|
3
|
+
isViewValidationRecord as isRecord,
|
|
4
|
+
SERVER_CONTROLLED_VIEW_FIELD_ROOTS,
|
|
5
|
+
} from './view-validation-helpers.js';
|
|
6
|
+
import type { ViewElasticsearchQuery } from './views.js';
|
|
7
|
+
|
|
8
|
+
export interface ViewQueryValidationIssue {
|
|
9
|
+
path: string;
|
|
10
|
+
message: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ViewQueryValidationOptions {
|
|
14
|
+
allowedFields?: ReadonlySet<string>;
|
|
15
|
+
allowedOperators?: ReadonlyMap<string, ReadonlySet<string>>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const ALLOWED_QUERY_TYPES = new Set([
|
|
19
|
+
'bool',
|
|
20
|
+
'match',
|
|
21
|
+
'multi_match',
|
|
22
|
+
'simple_query_string',
|
|
23
|
+
'term',
|
|
24
|
+
'terms',
|
|
25
|
+
'range',
|
|
26
|
+
'exists',
|
|
27
|
+
'ids',
|
|
28
|
+
'prefix',
|
|
29
|
+
'match_all',
|
|
30
|
+
]);
|
|
31
|
+
const MAX_QUERY_CLAUSES = 100;
|
|
32
|
+
const FORBIDDEN_FIELD_ROOTS = new Set<string>(SERVER_CONTROLLED_VIEW_FIELD_ROOTS);
|
|
33
|
+
|
|
34
|
+
function addFieldIssue(
|
|
35
|
+
issues: ViewQueryValidationIssue[],
|
|
36
|
+
field: unknown,
|
|
37
|
+
path: string,
|
|
38
|
+
options: ViewQueryValidationOptions,
|
|
39
|
+
): void {
|
|
40
|
+
if (typeof field !== 'string' || !FIELD_PATTERN.test(field)) {
|
|
41
|
+
issues.push({ path, message: 'must be a dot-separated field name' });
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (FORBIDDEN_FIELD_ROOTS.has(field.split('.')[0])) {
|
|
45
|
+
issues.push({ path, message: `field '${field}' is server-controlled` });
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (options.allowedFields && !options.allowedFields.has(field)) {
|
|
49
|
+
issues.push({ path, message: `field '${field}' is not available to this View` });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function addOperatorIssue(
|
|
54
|
+
issues: ViewQueryValidationIssue[],
|
|
55
|
+
field: string,
|
|
56
|
+
operator: string,
|
|
57
|
+
path: string,
|
|
58
|
+
options: ViewQueryValidationOptions,
|
|
59
|
+
): void {
|
|
60
|
+
const allowed = options.allowedOperators?.get(field);
|
|
61
|
+
if (allowed && !allowed.has(operator)) {
|
|
62
|
+
issues.push({
|
|
63
|
+
path,
|
|
64
|
+
message: `operator '${operator}' is not available for field '${field}'`,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function validateSingleFieldClause(
|
|
70
|
+
issues: ViewQueryValidationIssue[],
|
|
71
|
+
value: unknown,
|
|
72
|
+
path: string,
|
|
73
|
+
validateValue: (fieldValue: unknown, valuePath: string, field: string) => void,
|
|
74
|
+
options: ViewQueryValidationOptions,
|
|
75
|
+
): void {
|
|
76
|
+
if (!isRecord(value)) {
|
|
77
|
+
issues.push({ path, message: 'must be an object keyed by field name' });
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const entries = Object.entries(value);
|
|
81
|
+
if (entries.length !== 1) {
|
|
82
|
+
issues.push({ path, message: 'must reference exactly one field' });
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const [field, fieldValue] = entries[0];
|
|
86
|
+
addFieldIssue(issues, field, `${path}.${field}`, options);
|
|
87
|
+
validateValue(fieldValue, `${path}.${field}`, field);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function validateQueryNode(
|
|
91
|
+
issues: ViewQueryValidationIssue[],
|
|
92
|
+
value: unknown,
|
|
93
|
+
path: string,
|
|
94
|
+
state: { clauses: number },
|
|
95
|
+
options: ViewQueryValidationOptions,
|
|
96
|
+
): void {
|
|
97
|
+
state.clauses++;
|
|
98
|
+
if (state.clauses > MAX_QUERY_CLAUSES) {
|
|
99
|
+
issues.push({ path, message: `exceeds the maximum of ${MAX_QUERY_CLAUSES} query clauses` });
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (!isRecord(value)) {
|
|
103
|
+
issues.push({ path, message: 'must be an Elasticsearch query object' });
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const entries = Object.entries(value);
|
|
107
|
+
if (entries.length !== 1) {
|
|
108
|
+
issues.push({ path, message: 'must contain exactly one query clause' });
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const [type, body] = entries[0];
|
|
112
|
+
if (!ALLOWED_QUERY_TYPES.has(type)) {
|
|
113
|
+
issues.push({ path: `${path}.${type}`, message: `query type '${type}' is not supported` });
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (type === 'match_all') {
|
|
118
|
+
if (!isRecord(body)) {
|
|
119
|
+
issues.push({ path: `${path}.match_all`, message: 'must be an object' });
|
|
120
|
+
}
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (type === 'bool') {
|
|
124
|
+
if (!isRecord(body)) {
|
|
125
|
+
issues.push({ path: `${path}.bool`, message: 'must be an object' });
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const allowed = new Set(['must', 'filter', 'should', 'must_not', 'minimum_should_match']);
|
|
129
|
+
for (const key of Object.keys(body)) {
|
|
130
|
+
if (!allowed.has(key)) {
|
|
131
|
+
issues.push({ path: `${path}.bool.${key}`, message: 'is not supported' });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
for (const key of ['must', 'filter', 'should', 'must_not'] as const) {
|
|
135
|
+
const child = body[key];
|
|
136
|
+
if (child === undefined) continue;
|
|
137
|
+
const children = Array.isArray(child) ? child : [child];
|
|
138
|
+
if (children.length > 50) {
|
|
139
|
+
issues.push({ path: `${path}.bool.${key}`, message: 'must contain at most 50 clauses' });
|
|
140
|
+
}
|
|
141
|
+
children.forEach((item, index) => {
|
|
142
|
+
validateQueryNode(issues, item, `${path}.bool.${key}[${index}]`, state, options);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
const minimumShouldMatch = body.minimum_should_match;
|
|
146
|
+
if (
|
|
147
|
+
minimumShouldMatch !== undefined &&
|
|
148
|
+
typeof minimumShouldMatch !== 'number' &&
|
|
149
|
+
typeof minimumShouldMatch !== 'string'
|
|
150
|
+
) {
|
|
151
|
+
issues.push({ path: `${path}.bool.minimum_should_match`, message: 'must be a number or string' });
|
|
152
|
+
}
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (type === 'exists') {
|
|
156
|
+
if (!isRecord(body)) {
|
|
157
|
+
issues.push({ path: `${path}.exists`, message: 'must be an object' });
|
|
158
|
+
} else {
|
|
159
|
+
addFieldIssue(issues, body.field, `${path}.exists.field`, options);
|
|
160
|
+
if (typeof body.field === 'string') {
|
|
161
|
+
addOperatorIssue(issues, body.field, 'exists', `${path}.exists.field`, options);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (type === 'ids') {
|
|
167
|
+
if (!isRecord(body) || !Array.isArray(body.values) || body.values.length > 1_000) {
|
|
168
|
+
issues.push({ path: `${path}.ids.values`, message: 'must be an array containing at most 1000 IDs' });
|
|
169
|
+
} else if (body.values.some((id) => typeof id !== 'string')) {
|
|
170
|
+
issues.push({ path: `${path}.ids.values`, message: 'must contain only strings' });
|
|
171
|
+
}
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (type === 'multi_match' || type === 'simple_query_string') {
|
|
175
|
+
if (!isRecord(body) || typeof body.query !== 'string') {
|
|
176
|
+
issues.push({ path: `${path}.${type}.query`, message: 'must be a string' });
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (!Array.isArray(body.fields) || body.fields.length === 0 || body.fields.length > 20) {
|
|
180
|
+
issues.push({ path: `${path}.${type}.fields`, message: 'must contain between 1 and 20 fields' });
|
|
181
|
+
} else {
|
|
182
|
+
body.fields.forEach((rawField, index) => {
|
|
183
|
+
const field = typeof rawField === 'string' ? rawField.split('^')[0] : rawField;
|
|
184
|
+
addFieldIssue(issues, field, `${path}.${type}.fields[${index}]`, options);
|
|
185
|
+
if (typeof field === 'string') {
|
|
186
|
+
addOperatorIssue(issues, field, type, `${path}.${type}.fields[${index}]`, options);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (isRecord(body)) {
|
|
194
|
+
const fieldEntries = Object.entries(body);
|
|
195
|
+
if (fieldEntries.length === 1) {
|
|
196
|
+
addOperatorIssue(issues, fieldEntries[0][0], type, `${path}.${type}.${fieldEntries[0][0]}`, options);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
validateSingleFieldClause(
|
|
200
|
+
issues,
|
|
201
|
+
body,
|
|
202
|
+
`${path}.${type}`,
|
|
203
|
+
(fieldValue, valuePath, field) => {
|
|
204
|
+
if (type === 'terms') {
|
|
205
|
+
if (!Array.isArray(fieldValue) || fieldValue.length > 1_000) {
|
|
206
|
+
issues.push({ path: valuePath, message: 'must be an array containing at most 1000 values' });
|
|
207
|
+
}
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (type === 'range') {
|
|
211
|
+
if (!isRecord(fieldValue)) {
|
|
212
|
+
issues.push({ path: valuePath, message: 'must be a range object' });
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const allowed = new Set(['gt', 'gte', 'lt', 'lte']);
|
|
216
|
+
if (Object.keys(fieldValue).length === 0 || Object.keys(fieldValue).some((key) => !allowed.has(key))) {
|
|
217
|
+
issues.push({ path: valuePath, message: 'supports only gt, gte, lt, and lte' });
|
|
218
|
+
}
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (type === 'prefix') {
|
|
222
|
+
if (field !== 'location') {
|
|
223
|
+
issues.push({ path: valuePath, message: 'prefix queries are supported only for location' });
|
|
224
|
+
}
|
|
225
|
+
const prefix = isRecord(fieldValue) ? fieldValue.value : fieldValue;
|
|
226
|
+
if (typeof prefix !== 'string' || !prefix.startsWith('/') || !prefix.endsWith('/')) {
|
|
227
|
+
issues.push({ path: valuePath, message: 'must be a boundary-aware absolute location prefix' });
|
|
228
|
+
}
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (type === 'match') {
|
|
232
|
+
const query = isRecord(fieldValue) ? fieldValue.query : fieldValue;
|
|
233
|
+
if (typeof query !== 'string') {
|
|
234
|
+
issues.push({ path: valuePath, message: 'must contain a string query' });
|
|
235
|
+
}
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (type === 'term' && isRecord(fieldValue)) {
|
|
239
|
+
const allowed = new Set(['value', 'boost', 'case_insensitive']);
|
|
240
|
+
if (!('value' in fieldValue) || Object.keys(fieldValue).some((key) => !allowed.has(key))) {
|
|
241
|
+
issues.push({
|
|
242
|
+
path: valuePath,
|
|
243
|
+
message: 'supports only value, boost, and case_insensitive options',
|
|
244
|
+
});
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (isRecord(fieldValue.value) || Array.isArray(fieldValue.value)) {
|
|
248
|
+
issues.push({ path: `${valuePath}.value`, message: 'must be a scalar value' });
|
|
249
|
+
}
|
|
250
|
+
if (fieldValue.boost !== undefined && typeof fieldValue.boost !== 'number') {
|
|
251
|
+
issues.push({ path: `${valuePath}.boost`, message: 'must be a number' });
|
|
252
|
+
}
|
|
253
|
+
if (fieldValue.case_insensitive !== undefined && typeof fieldValue.case_insensitive !== 'boolean') {
|
|
254
|
+
issues.push({ path: `${valuePath}.case_insensitive`, message: 'must be a boolean' });
|
|
255
|
+
}
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (isRecord(fieldValue) || Array.isArray(fieldValue)) {
|
|
259
|
+
issues.push({ path: valuePath, message: 'must be a scalar value' });
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
options,
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function normalizeViewQueryNode(value: unknown, state: { clauses: number }): unknown {
|
|
267
|
+
state.clauses++;
|
|
268
|
+
if (state.clauses > MAX_QUERY_CLAUSES) return value;
|
|
269
|
+
if (!isRecord(value)) return value;
|
|
270
|
+
const entries = Object.entries(value);
|
|
271
|
+
if (entries.length !== 1) return value;
|
|
272
|
+
const [type, body] = entries[0];
|
|
273
|
+
if (type === 'bool' && isRecord(body)) {
|
|
274
|
+
const normalizedBody: Record<string, unknown> = { ...body };
|
|
275
|
+
for (const key of ['must', 'filter', 'should', 'must_not'] as const) {
|
|
276
|
+
const child = body[key];
|
|
277
|
+
if (Array.isArray(child)) {
|
|
278
|
+
normalizedBody[key] = child.map((item) => normalizeViewQueryNode(item, state));
|
|
279
|
+
} else if (child !== undefined) {
|
|
280
|
+
normalizedBody[key] = normalizeViewQueryNode(child, state);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return { bool: normalizedBody };
|
|
284
|
+
}
|
|
285
|
+
if (type !== 'term' || !isRecord(body)) return value;
|
|
286
|
+
const fieldEntries = Object.entries(body);
|
|
287
|
+
if (fieldEntries.length !== 1) return value;
|
|
288
|
+
const [field, fieldValue] = fieldEntries[0];
|
|
289
|
+
if (!isRecord(fieldValue) || Object.keys(fieldValue).length !== 1 || !('value' in fieldValue)) return value;
|
|
290
|
+
return { term: { [field]: fieldValue.value } };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function normalizeViewQuery(value: unknown): unknown {
|
|
294
|
+
return normalizeViewQueryNode(value, { clauses: 0 });
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function validateSupportedViewQuery(
|
|
298
|
+
value: unknown,
|
|
299
|
+
path = 'query',
|
|
300
|
+
options: ViewQueryValidationOptions = {},
|
|
301
|
+
): ViewQueryValidationIssue[] {
|
|
302
|
+
const issues: ViewQueryValidationIssue[] = [];
|
|
303
|
+
validateQueryNode(issues, normalizeViewQuery(value), path, { clauses: 0 }, options);
|
|
304
|
+
return issues;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function asSupportedViewQuery(
|
|
308
|
+
value: unknown,
|
|
309
|
+
path = 'query',
|
|
310
|
+
options: ViewQueryValidationOptions = {},
|
|
311
|
+
): ViewElasticsearchQuery {
|
|
312
|
+
const normalized = normalizeViewQuery(value);
|
|
313
|
+
const issues: ViewQueryValidationIssue[] = [];
|
|
314
|
+
validateQueryNode(issues, normalized, path, { clauses: 0 }, options);
|
|
315
|
+
if (issues.length > 0) {
|
|
316
|
+
throw new Error(issues.map((issue) => `${issue.path} ${issue.message}`).join('; '));
|
|
317
|
+
}
|
|
318
|
+
return normalized as ViewElasticsearchQuery;
|
|
319
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export const VIEW_CONFIGURATION_ID_PATTERN_SOURCE = '^[a-z][a-z0-9_-]*$';
|
|
2
|
+
export const ELASTICSEARCH_FIELD_PATH_PATTERN_SOURCE = '^[A-Za-z0-9_]+(?:\\.[A-Za-z0-9_]+)*$';
|
|
3
|
+
|
|
4
|
+
export const VIEW_CONFIGURATION_ID_PATTERN = new RegExp(VIEW_CONFIGURATION_ID_PATTERN_SOURCE);
|
|
5
|
+
export const ELASTICSEARCH_FIELD_PATH_PATTERN = new RegExp(ELASTICSEARCH_FIELD_PATH_PATTERN_SOURCE);
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Elasticsearch roots owned by the server and unavailable to View-authored
|
|
9
|
+
* queries or dynamic collection filters.
|
|
10
|
+
*/
|
|
11
|
+
export const SERVER_CONTROLLED_VIEW_FIELD_ROOTS = [
|
|
12
|
+
'security',
|
|
13
|
+
'revision',
|
|
14
|
+
'is_deleted',
|
|
15
|
+
'embeddings_text',
|
|
16
|
+
'embeddings_image',
|
|
17
|
+
'embeddings_properties',
|
|
18
|
+
'_indexing_metadata',
|
|
19
|
+
] as const;
|
|
20
|
+
|
|
21
|
+
/** @deprecated Use ELASTICSEARCH_FIELD_PATH_PATTERN_SOURCE. */
|
|
22
|
+
export const VIEW_FIELD_PATTERN_SOURCE = ELASTICSEARCH_FIELD_PATH_PATTERN_SOURCE;
|
|
23
|
+
/** @deprecated Use ELASTICSEARCH_FIELD_PATH_PATTERN. */
|
|
24
|
+
export const VIEW_FIELD_PATTERN = ELASTICSEARCH_FIELD_PATH_PATTERN;
|
|
25
|
+
|
|
26
|
+
export function isViewValidationRecord(value: unknown): value is Record<string, unknown> {
|
|
27
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
28
|
+
}
|