@voyant-travel/reporting-contracts 0.1.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,293 @@
1
+ import { z } from "zod";
2
+ // Graph entity ids commonly include scoped package names and a `#facet.entity` suffix.
3
+ const identifierPattern = /^[A-Za-z0-9@][A-Za-z0-9._/@#-]*$/;
4
+ export const reportingIdentifierSchema = z.string().trim().min(1).max(160).regex(identifierPattern);
5
+ export const reportingVersionSchema = z.number().int().positive();
6
+ export const reportingScopeSchema = z.string().trim().min(3).max(160);
7
+ export const reportScalarSchema = z.union([z.string(), z.number().finite(), z.boolean(), z.null()]);
8
+ export const reportParameterValueSchema = z.union([
9
+ reportScalarSchema,
10
+ z.array(reportScalarSchema).max(500),
11
+ ]);
12
+ export const reportParametersSchema = z.record(z.string(), reportParameterValueSchema);
13
+ export const reportFieldValueTypeSchema = z.enum([
14
+ "string",
15
+ "integer",
16
+ "number",
17
+ "boolean",
18
+ "date",
19
+ "datetime",
20
+ "currency",
21
+ "json",
22
+ ]);
23
+ export const reportFieldSensitivitySchema = z.enum(["public", "internal", "pii", "sensitive"]);
24
+ export const reportAggregationSchema = z.enum([
25
+ "count",
26
+ "countDistinct",
27
+ "sum",
28
+ "average",
29
+ "minimum",
30
+ "maximum",
31
+ ]);
32
+ export const reportDatasetFieldSchema = z
33
+ .object({
34
+ id: reportingIdentifierSchema,
35
+ label: z.string().trim().min(1).max(160),
36
+ description: z.string().trim().max(1_000).optional(),
37
+ role: z.enum(["dimension", "measure"]),
38
+ valueType: reportFieldValueTypeSchema,
39
+ sensitivity: reportFieldSensitivitySchema.default("internal"),
40
+ requiredScopes: z.array(reportingScopeSchema).max(20).default([]),
41
+ aggregations: z.array(reportAggregationSchema).max(6).default([]),
42
+ })
43
+ .strict();
44
+ export const reportDatasetDefinitionSchema = z
45
+ .object({
46
+ id: reportingIdentifierSchema,
47
+ version: reportingVersionSchema,
48
+ label: z.string().trim().min(1).max(160),
49
+ description: z.string().trim().max(2_000).optional(),
50
+ grain: z.string().trim().min(1).max(300),
51
+ requiredScopes: z.array(reportingScopeSchema).max(20).default([]),
52
+ fields: z.array(reportDatasetFieldSchema).min(1).max(250),
53
+ defaultLimit: z.number().int().positive().max(1_000).default(100),
54
+ maximumLimit: z.number().int().positive().max(5_000).default(1_000),
55
+ })
56
+ .strict()
57
+ .superRefine((dataset, context) => {
58
+ const ids = new Set();
59
+ for (const [index, field] of dataset.fields.entries()) {
60
+ if (ids.has(field.id)) {
61
+ context.addIssue({
62
+ code: "custom",
63
+ message: `Duplicate dataset field ${JSON.stringify(field.id)}.`,
64
+ path: ["fields", index, "id"],
65
+ });
66
+ }
67
+ ids.add(field.id);
68
+ }
69
+ if (dataset.defaultLimit > dataset.maximumLimit) {
70
+ context.addIssue({
71
+ code: "custom",
72
+ message: "defaultLimit cannot exceed maximumLimit.",
73
+ path: ["defaultLimit"],
74
+ });
75
+ }
76
+ });
77
+ export const reportValueReferenceSchema = z.discriminatedUnion("kind", [
78
+ z.object({ kind: z.literal("literal"), value: reportParameterValueSchema }).strict(),
79
+ z.object({ kind: z.literal("parameter"), name: reportingIdentifierSchema }).strict(),
80
+ ]);
81
+ export const reportFilterSchema = z
82
+ .object({
83
+ field: reportingIdentifierSchema,
84
+ operator: z.enum([
85
+ "equal",
86
+ "notEqual",
87
+ "in",
88
+ "notIn",
89
+ "greaterThan",
90
+ "greaterThanOrEqual",
91
+ "lessThan",
92
+ "lessThanOrEqual",
93
+ "between",
94
+ "contains",
95
+ "isNull",
96
+ "isNotNull",
97
+ ]),
98
+ value: reportValueReferenceSchema.optional(),
99
+ })
100
+ .strict();
101
+ export const reportSelectionSchema = z.discriminatedUnion("kind", [
102
+ z
103
+ .object({
104
+ kind: z.literal("field"),
105
+ field: reportingIdentifierSchema,
106
+ as: reportingIdentifierSchema.optional(),
107
+ })
108
+ .strict(),
109
+ z
110
+ .object({
111
+ kind: z.literal("aggregate"),
112
+ operation: reportAggregationSchema,
113
+ field: reportingIdentifierSchema.optional(),
114
+ as: reportingIdentifierSchema,
115
+ })
116
+ .strict(),
117
+ ]);
118
+ export const reportGroupSchema = z
119
+ .object({
120
+ field: reportingIdentifierSchema,
121
+ timeGrain: z.enum(["day", "week", "month", "quarter", "year"]).optional(),
122
+ })
123
+ .strict();
124
+ export const reportOrderSchema = z
125
+ .object({
126
+ by: reportingIdentifierSchema,
127
+ direction: z.enum(["ascending", "descending"]).default("ascending"),
128
+ })
129
+ .strict();
130
+ export const reportQuerySchema = z
131
+ .object({
132
+ dataset: z.object({
133
+ id: reportingIdentifierSchema,
134
+ version: reportingVersionSchema.optional(),
135
+ }),
136
+ select: z.array(reportSelectionSchema).min(1).max(50),
137
+ filters: z.array(reportFilterSchema).max(50).default([]),
138
+ groupBy: z.array(reportGroupSchema).max(20).default([]),
139
+ orderBy: z.array(reportOrderSchema).max(10).default([]),
140
+ limit: z.number().int().positive().max(5_000).optional(),
141
+ })
142
+ .strict();
143
+ export const reportColumnSchema = z
144
+ .object({
145
+ id: reportingIdentifierSchema,
146
+ label: z.string().trim().min(1).max(160),
147
+ valueType: reportFieldValueTypeSchema,
148
+ })
149
+ .strict();
150
+ export const reportResultSchema = z
151
+ .object({
152
+ columns: z.array(reportColumnSchema).max(100),
153
+ rows: z.array(z.record(z.string(), z.unknown())).max(5_000),
154
+ truncated: z.boolean().default(false),
155
+ warnings: z.array(z.string().max(500)).max(50).default([]),
156
+ })
157
+ .strict();
158
+ export const reportVisualizationSchema = z
159
+ .object({
160
+ type: z.enum(["kpi", "table", "line", "bar", "pie"]),
161
+ options: z.record(z.string(), z.unknown()).default({}),
162
+ })
163
+ .strict();
164
+ export const reportGridSizeSchema = z
165
+ .object({ width: z.number().int().min(1).max(12), height: z.number().int().min(1).max(100) })
166
+ .strict();
167
+ export const reportGridLayoutSchema = reportGridSizeSchema
168
+ .extend({ x: z.number().int().min(0).max(11), y: z.number().int().min(0).max(10_000) })
169
+ .superRefine((layout, context) => {
170
+ if (layout.x + layout.width > 12) {
171
+ context.addIssue({ code: "custom", message: "Widget extends beyond the 12-column grid." });
172
+ }
173
+ });
174
+ export const reportWidgetDefinitionSchema = z
175
+ .object({
176
+ id: reportingIdentifierSchema,
177
+ version: reportingVersionSchema,
178
+ label: z.string().trim().min(1).max(160),
179
+ description: z.string().trim().max(1_000).optional(),
180
+ query: reportQuerySchema,
181
+ visualization: reportVisualizationSchema,
182
+ defaultSize: reportGridSizeSchema,
183
+ minimumSize: reportGridSizeSchema.optional(),
184
+ maximumSize: reportGridSizeSchema.optional(),
185
+ })
186
+ .strict();
187
+ export const reportWidgetInstanceSchema = z
188
+ .object({
189
+ id: reportingIdentifierSchema,
190
+ source: z.discriminatedUnion("kind", [
191
+ z
192
+ .object({
193
+ kind: z.literal("preset"),
194
+ widgetId: reportingIdentifierSchema,
195
+ version: reportingVersionSchema.optional(),
196
+ })
197
+ .strict(),
198
+ z.object({ kind: z.literal("custom"), definition: reportWidgetDefinitionSchema }).strict(),
199
+ ]),
200
+ title: z.string().trim().min(1).max(160).optional(),
201
+ layout: reportGridLayoutSchema,
202
+ })
203
+ .strict();
204
+ export const reportTemplateDefinitionSchema = z
205
+ .object({
206
+ id: reportingIdentifierSchema,
207
+ version: reportingVersionSchema,
208
+ label: z.string().trim().min(1).max(160),
209
+ description: z.string().trim().max(2_000).optional(),
210
+ parameters: z.array(reportingIdentifierSchema).max(50).default([]),
211
+ widgets: z.array(reportWidgetInstanceSchema).max(50),
212
+ })
213
+ .strict();
214
+ export const reportDraftSchema = z
215
+ .object({
216
+ parameters: reportParametersSchema.default({}),
217
+ widgets: z.array(reportWidgetInstanceSchema).max(50).default([]),
218
+ })
219
+ .strict()
220
+ .superRefine((draft, context) => {
221
+ const ids = new Set();
222
+ for (const [index, widget] of draft.widgets.entries()) {
223
+ if (ids.has(widget.id)) {
224
+ context.addIssue({
225
+ code: "custom",
226
+ message: `Duplicate widget instance ${JSON.stringify(widget.id)}.`,
227
+ path: ["widgets", index, "id"],
228
+ });
229
+ }
230
+ ids.add(widget.id);
231
+ }
232
+ for (let leftIndex = 0; leftIndex < draft.widgets.length; leftIndex += 1) {
233
+ const left = draft.widgets[leftIndex];
234
+ if (!left)
235
+ continue;
236
+ for (let rightIndex = leftIndex + 1; rightIndex < draft.widgets.length; rightIndex += 1) {
237
+ const right = draft.widgets[rightIndex];
238
+ if (!right)
239
+ continue;
240
+ const overlaps = left.layout.x < right.layout.x + right.layout.width &&
241
+ left.layout.x + left.layout.width > right.layout.x &&
242
+ left.layout.y < right.layout.y + right.layout.height &&
243
+ left.layout.y + left.layout.height > right.layout.y;
244
+ if (overlaps) {
245
+ context.addIssue({
246
+ code: "custom",
247
+ message: `Widget ${JSON.stringify(right.id)} overlaps ${JSON.stringify(left.id)}.`,
248
+ path: ["widgets", rightIndex, "layout"],
249
+ });
250
+ }
251
+ }
252
+ }
253
+ });
254
+ export const createReportDefinitionSchema = z
255
+ .object({
256
+ name: z.string().trim().min(1).max(160),
257
+ description: z.string().trim().max(2_000).nullable().optional(),
258
+ sourceTemplateId: reportingIdentifierSchema.nullable().optional(),
259
+ sourceTemplateVersion: reportingVersionSchema.nullable().optional(),
260
+ draft: reportDraftSchema.default({ parameters: {}, widgets: [] }),
261
+ })
262
+ .strict();
263
+ export const updateReportDefinitionSchema = z
264
+ .object({
265
+ revision: z.number().int().positive(),
266
+ name: z.string().trim().min(1).max(160).optional(),
267
+ description: z.string().trim().max(2_000).nullable().optional(),
268
+ draft: reportDraftSchema.optional(),
269
+ })
270
+ .strict();
271
+ export const listReportDefinitionsQuerySchema = z.object({
272
+ limit: z.coerce.number().int().positive().max(100).default(25),
273
+ offset: z.coerce.number().int().nonnegative().default(0),
274
+ });
275
+ export const createReportVersionSchema = z
276
+ .object({ expectedRevision: z.number().int().positive() })
277
+ .strict();
278
+ export const executeReportVersionSchema = z
279
+ .object({ parameters: reportParametersSchema.default({}) })
280
+ .strict();
281
+ export const previewReportQuerySchema = z
282
+ .object({ query: reportQuerySchema, parameters: reportParametersSchema.default({}) })
283
+ .strict();
284
+ export const parseReportQuerySourceSchema = z
285
+ .object({ source: z.string().trim().min(1).max(10_000) })
286
+ .strict();
287
+ export const instantiateReportTemplateSchema = z
288
+ .object({
289
+ name: z.string().trim().min(1).max(160),
290
+ description: z.string().trim().max(2_000).nullable().optional(),
291
+ version: reportingVersionSchema.optional(),
292
+ })
293
+ .strict();
@@ -0,0 +1,3 @@
1
+ export * from "./contracts.js";
2
+ export * from "./query-language.js";
3
+ export * from "./runtime-port.js";
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export * from "./contracts.js";
2
+ export * from "./query-language.js";
3
+ export * from "./runtime-port.js";
@@ -0,0 +1,11 @@
1
+ import type { ReportQuery } from "./contracts.js";
2
+ /** Error raised when the bounded report query language cannot compile a source string. */
3
+ export declare class ReportQuerySyntaxError extends Error {
4
+ constructor(message: string);
5
+ }
6
+ /**
7
+ * Compile the intentionally small reporting language into the public query AST.
8
+ * It is SQL-like for familiarity but has no joins, subqueries, statements, or
9
+ * table access. Dataset owners remain responsible for executing the AST.
10
+ */
11
+ export declare function parseReportQuery(source: string): ReportQuery;
@@ -0,0 +1,228 @@
1
+ import { reportingIdentifierSchema, reportQuerySchema } from "./contracts.js";
2
+ const forbiddenSource = /(?:;|--|\/\*|\*\/|\b(?:join|union|insert|update|delete|drop|alter|create|grant|revoke|truncate|execute|call)\b)/i;
3
+ const aggregateFunctions = {
4
+ count: "count",
5
+ countdistinct: "countDistinct",
6
+ sum: "sum",
7
+ average: "average",
8
+ avg: "average",
9
+ minimum: "minimum",
10
+ min: "minimum",
11
+ maximum: "maximum",
12
+ max: "maximum",
13
+ };
14
+ /** Error raised when the bounded report query language cannot compile a source string. */
15
+ export class ReportQuerySyntaxError extends Error {
16
+ constructor(message) {
17
+ super(message);
18
+ this.name = "ReportQuerySyntaxError";
19
+ }
20
+ }
21
+ /**
22
+ * Compile the intentionally small reporting language into the public query AST.
23
+ * It is SQL-like for familiarity but has no joins, subqueries, statements, or
24
+ * table access. Dataset owners remain responsible for executing the AST.
25
+ */
26
+ export function parseReportQuery(source) {
27
+ const normalized = source.trim().replace(/\s+/g, " ");
28
+ if (normalized.length === 0)
29
+ throw syntax("Query source is empty.");
30
+ if (normalized.length > 10_000)
31
+ throw syntax("Query source exceeds 10,000 characters.");
32
+ if (forbiddenSource.test(normalized))
33
+ throw syntax("Query contains a forbidden construct.");
34
+ if (normalized.includes("*"))
35
+ throw syntax("Wildcard selections are not supported.");
36
+ const clauses = normalized.match(/^from\s+(\S+?)(?:\s+where\s+(.+?))?(?:\s+group\s+by\s+(.+?))?\s+select\s+(.+?)(?:\s+order\s+by\s+(.+?))?(?:\s+limit\s+(\d+))?$/i);
37
+ if (!clauses) {
38
+ throw syntax("Expected `from <dataset> [where ...] [group by ...] select ... [order by ...] [limit ...]`.");
39
+ }
40
+ const [, datasetSource, filterSource, groupSource, selectSource, orderSource, limitSource] = clauses;
41
+ const dataset = parseIdentifier(datasetSource, "dataset");
42
+ const query = {
43
+ dataset: { id: dataset },
44
+ select: splitList(required(selectSource, "select")).map(parseSelection),
45
+ filters: filterSource ? splitAnd(filterSource).map(parseFilter) : [],
46
+ groupBy: groupSource ? splitList(groupSource).map(parseGroup) : [],
47
+ orderBy: orderSource ? splitList(orderSource).map(parseOrder) : [],
48
+ limit: limitSource ? Number.parseInt(limitSource, 10) : undefined,
49
+ };
50
+ const parsed = reportQuerySchema.safeParse(query);
51
+ if (!parsed.success)
52
+ throw syntax(parsed.error.issues[0]?.message ?? "Invalid report query.");
53
+ return parsed.data;
54
+ }
55
+ function parseSelection(source) {
56
+ const aggregate = source.match(/^([a-z]+)\(([^)]*)\)\s+as\s+([a-z0-9][a-z0-9._/-]*)$/i);
57
+ if (aggregate) {
58
+ const operation = aggregateFunctions[aggregate[1]?.toLowerCase()];
59
+ if (!operation)
60
+ throw syntax(`Unsupported aggregate function ${JSON.stringify(aggregate[1])}.`);
61
+ const fieldSource = aggregate[2]?.trim();
62
+ if (operation !== "count" && !fieldSource) {
63
+ throw syntax(`${aggregate[1]}() requires a field.`);
64
+ }
65
+ return {
66
+ kind: "aggregate",
67
+ operation,
68
+ ...(fieldSource ? { field: parseIdentifier(fieldSource, "aggregate field") } : {}),
69
+ as: parseIdentifier(aggregate[3], "selection alias"),
70
+ };
71
+ }
72
+ const field = source.match(/^([a-z0-9][a-z0-9._/-]*)(?:\s+as\s+([a-z0-9][a-z0-9._/-]*))?$/i);
73
+ if (!field)
74
+ throw syntax(`Unsupported selection ${JSON.stringify(source)}.`);
75
+ return {
76
+ kind: "field",
77
+ field: parseIdentifier(field[1], "selected field"),
78
+ ...(field[2] ? { as: parseIdentifier(field[2], "selection alias") } : {}),
79
+ };
80
+ }
81
+ function parseFilter(source) {
82
+ const nullFilter = source.match(/^([a-z0-9][a-z0-9._/-]*)\s+is\s+(not\s+)?null$/i);
83
+ if (nullFilter) {
84
+ return {
85
+ field: parseIdentifier(nullFilter[1], "filter field"),
86
+ operator: nullFilter[2] ? "isNotNull" : "isNull",
87
+ };
88
+ }
89
+ const match = source.match(/^([a-z0-9][a-z0-9._/-]*)\s+(not\s+in|in|contains|>=|<=|!=|=|>|<)\s+(.+)$/i);
90
+ if (!match)
91
+ throw syntax(`Unsupported filter ${JSON.stringify(source)}.`);
92
+ const operator = {
93
+ "=": "equal",
94
+ "!=": "notEqual",
95
+ ">": "greaterThan",
96
+ ">=": "greaterThanOrEqual",
97
+ "<": "lessThan",
98
+ "<=": "lessThanOrEqual",
99
+ in: "in",
100
+ "not in": "notIn",
101
+ contains: "contains",
102
+ };
103
+ const operatorKey = required(match[2], "filter operator")
104
+ .toLowerCase()
105
+ .replace(/\s+/g, " ");
106
+ return {
107
+ field: parseIdentifier(match[1], "filter field"),
108
+ operator: operator[operatorKey],
109
+ value: parseValueReference(required(match[3], "filter value")),
110
+ };
111
+ }
112
+ function parseValueReference(source) {
113
+ const value = source.trim();
114
+ if (value.startsWith("$")) {
115
+ return { kind: "parameter", name: parseIdentifier(value.slice(1), "parameter") };
116
+ }
117
+ if (value.startsWith("[") && value.endsWith("]")) {
118
+ const inner = value.slice(1, -1).trim();
119
+ const values = inner ? splitList(inner).map(parseLiteral) : [];
120
+ return { kind: "literal", value: values };
121
+ }
122
+ return { kind: "literal", value: parseLiteral(value) };
123
+ }
124
+ function parseLiteral(source) {
125
+ const value = source.trim();
126
+ if (/^'.*'$/.test(value) || /^".*"$/.test(value))
127
+ return value.slice(1, -1);
128
+ if (/^-?\d+(?:\.\d+)?$/.test(value))
129
+ return Number(value);
130
+ if (/^true$/i.test(value))
131
+ return true;
132
+ if (/^false$/i.test(value))
133
+ return false;
134
+ if (/^null$/i.test(value))
135
+ return null;
136
+ throw syntax(`Literal ${JSON.stringify(source)} must be quoted, numeric, boolean, or null.`);
137
+ }
138
+ function parseGroup(source) {
139
+ const bucket = source.match(/^(day|week|month|quarter|year)\(([a-z0-9][a-z0-9._/-]*)\)$/i);
140
+ if (bucket) {
141
+ return {
142
+ field: parseIdentifier(bucket[2], "group field"),
143
+ timeGrain: required(bucket[1], "time grain").toLowerCase(),
144
+ };
145
+ }
146
+ return { field: parseIdentifier(source, "group field") };
147
+ }
148
+ function parseOrder(source) {
149
+ const match = source.match(/^([a-z0-9][a-z0-9._/-]*)(?:\s+(asc|desc))?$/i);
150
+ if (!match)
151
+ throw syntax(`Unsupported ordering ${JSON.stringify(source)}.`);
152
+ return {
153
+ by: parseIdentifier(match[1], "order field"),
154
+ direction: match[2]?.toLowerCase() === "desc" ? "descending" : "ascending",
155
+ };
156
+ }
157
+ function splitList(source) {
158
+ const values = [];
159
+ let quote;
160
+ let depth = 0;
161
+ let start = 0;
162
+ for (let index = 0; index < source.length; index += 1) {
163
+ const character = source[index];
164
+ if (quote) {
165
+ if (character === quote && source[index - 1] !== "\\")
166
+ quote = undefined;
167
+ continue;
168
+ }
169
+ if (character === "'" || character === '"')
170
+ quote = character;
171
+ else if (character === "(" || character === "[")
172
+ depth += 1;
173
+ else if (character === ")" || character === "]")
174
+ depth -= 1;
175
+ else if (character === "," && depth === 0) {
176
+ values.push(source.slice(start, index).trim());
177
+ start = index + 1;
178
+ }
179
+ if (depth < 0)
180
+ throw syntax("Unbalanced query delimiters.");
181
+ }
182
+ if (quote || depth !== 0)
183
+ throw syntax("Unbalanced query delimiters.");
184
+ values.push(source.slice(start).trim());
185
+ if (values.some((value) => value.length === 0))
186
+ throw syntax("Query list contains an empty item.");
187
+ return values;
188
+ }
189
+ function splitAnd(source) {
190
+ // AND inside quoted literals remains part of the literal.
191
+ const parts = [];
192
+ let quote;
193
+ let start = 0;
194
+ for (let index = 0; index < source.length; index += 1) {
195
+ const character = source[index];
196
+ if (quote) {
197
+ if (character === quote && source[index - 1] !== "\\")
198
+ quote = undefined;
199
+ continue;
200
+ }
201
+ if (character === "'" || character === '"') {
202
+ quote = character;
203
+ continue;
204
+ }
205
+ if (source.slice(index).match(/^\s+and\s+/i)) {
206
+ const separator = required(source.slice(index).match(/^\s+and\s+/i)?.[0], "AND separator");
207
+ parts.push(source.slice(start, index).trim());
208
+ index += separator.length - 1;
209
+ start = index + 1;
210
+ }
211
+ }
212
+ parts.push(source.slice(start).trim());
213
+ return parts;
214
+ }
215
+ function parseIdentifier(value, label) {
216
+ const parsed = reportingIdentifierSchema.safeParse(value);
217
+ if (!parsed.success)
218
+ throw syntax(`Invalid ${label} ${JSON.stringify(value)}.`);
219
+ return parsed.data;
220
+ }
221
+ function required(value, label) {
222
+ if (!value)
223
+ throw syntax(`Missing ${label}.`);
224
+ return value;
225
+ }
226
+ function syntax(message) {
227
+ return new ReportQuerySyntaxError(message);
228
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,46 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { parseReportQuery, ReportQuerySyntaxError } from "./query-language.js";
3
+ describe("bounded report query language", () => {
4
+ it("compiles a single-dataset query with filters, grouping, ordering, and a limit", () => {
5
+ expect(parseReportQuery(`
6
+ from bookings
7
+ where status in ["confirmed", "completed"] and created_at >= $from
8
+ group by month(created_at)
9
+ select month as month, count() as bookings, sum(sell_amount) as revenue
10
+ order by month asc
11
+ limit 24
12
+ `)).toEqual({
13
+ dataset: { id: "bookings" },
14
+ filters: [
15
+ {
16
+ field: "status",
17
+ operator: "in",
18
+ value: { kind: "literal", value: ["confirmed", "completed"] },
19
+ },
20
+ {
21
+ field: "created_at",
22
+ operator: "greaterThanOrEqual",
23
+ value: { kind: "parameter", name: "from" },
24
+ },
25
+ ],
26
+ groupBy: [{ field: "created_at", timeGrain: "month" }],
27
+ select: [
28
+ { kind: "field", field: "month", as: "month" },
29
+ { kind: "aggregate", operation: "count", as: "bookings" },
30
+ { kind: "aggregate", operation: "sum", field: "sell_amount", as: "revenue" },
31
+ ],
32
+ orderBy: [{ by: "month", direction: "ascending" }],
33
+ limit: 24,
34
+ });
35
+ });
36
+ it.each([
37
+ "from bookings join invoices select count() as rows",
38
+ "delete from bookings",
39
+ "from bookings select *",
40
+ "from bookings select count() as rows; drop table bookings",
41
+ "from (select bookings) select count() as rows",
42
+ "from bookings select revenue + tax as total",
43
+ ])("rejects unsupported or unsafe source: %s", (source) => {
44
+ expect(() => parseReportQuery(source)).toThrow(ReportQuerySyntaxError);
45
+ });
46
+ });
@@ -0,0 +1,28 @@
1
+ import type { ReportDatasetDefinition, ReportParameters, ReportQuery, ReportResult, ReportTemplateDefinition, ReportWidgetDefinition } from "./contracts.js";
2
+ export interface ReportDatasetExecutionContext {
3
+ db: unknown;
4
+ actorId?: string;
5
+ grantedScopes: readonly string[];
6
+ signal?: AbortSignal;
7
+ }
8
+ export interface ReportDatasetExecutionInput {
9
+ query: ReportQuery;
10
+ parameters: ReportParameters;
11
+ maximumRows: number;
12
+ }
13
+ /** Executable half of a manifest-declared dataset. */
14
+ export interface ReportDatasetRuntime {
15
+ execute(context: ReportDatasetExecutionContext, input: ReportDatasetExecutionInput): Promise<ReportResult> | ReportResult;
16
+ }
17
+ /** Programmatic contribution shape for datasets which do not come from a package manifest. */
18
+ export interface ReportDatasetContribution extends ReportDatasetRuntime {
19
+ definition: ReportDatasetDefinition;
20
+ }
21
+ /** One module-owned contribution to the reporting catalog. */
22
+ export interface ReportingContributionRuntime {
23
+ namespace: string;
24
+ datasets?: readonly ReportDatasetContribution[];
25
+ widgets?: readonly ReportWidgetDefinition[];
26
+ templates?: readonly ReportTemplateDefinition[];
27
+ }
28
+ export declare const reportingContributionRuntimePort: import("@voyant-travel/core/project").VoyantPort<ReportingContributionRuntime>;
@@ -0,0 +1,18 @@
1
+ import { definePort } from "@voyant-travel/core/project";
2
+ export const reportingContributionRuntimePort = definePort({
3
+ id: "reporting.contribution",
4
+ test(provider) {
5
+ if (!provider || typeof provider !== "object") {
6
+ throw new Error("reporting.contribution provider must be an object.");
7
+ }
8
+ const contribution = provider;
9
+ if (typeof contribution.namespace !== "string" || contribution.namespace.length === 0) {
10
+ throw new Error("reporting.contribution provider must declare a namespace.");
11
+ }
12
+ for (const dataset of contribution.datasets ?? []) {
13
+ if (!dataset || typeof dataset !== "object" || typeof dataset.execute !== "function") {
14
+ throw new Error("reporting.contribution datasets must implement execute().");
15
+ }
16
+ }
17
+ },
18
+ });