@voyant-travel/reporting 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/service.d.ts CHANGED
@@ -2,7 +2,7 @@ import type { createReportDefinitionSchema, ReportParameters, updateReportDefini
2
2
  import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
3
3
  import type { z } from "zod";
4
4
  import type { ReportingRegistry } from "./registry.js";
5
- import { type ReportDefinitionRow, type ReportRunOutput } from "./schema.js";
5
+ import { type ReportDefinitionRow } from "./schema.js";
6
6
  export type CreateReportDefinitionInput = z.infer<typeof createReportDefinitionSchema>;
7
7
  export type UpdateReportDefinitionInput = z.infer<typeof updateReportDefinitionSchema>;
8
8
  export declare class ReportDefinitionRevisionConflictError extends Error {
@@ -11,9 +11,6 @@ export declare class ReportDefinitionRevisionConflictError extends Error {
11
11
  export declare class ReportingRecordNotFoundError extends Error {
12
12
  constructor(record: string);
13
13
  }
14
- export declare class ReportDefinitionRetentionConflictError extends Error {
15
- constructor();
16
- }
17
14
  export declare function createReportingService(registry: ReportingRegistry): {
18
15
  list(db: PostgresJsDatabase, input: {
19
16
  limit: number;
@@ -390,120 +387,42 @@ export declare function createReportingService(registry: ReportingRegistry): {
390
387
  updatedAt: Date;
391
388
  }>;
392
389
  remove(db: PostgresJsDatabase, id: string): Promise<boolean>;
393
- createVersion(db: PostgresJsDatabase, reportDefinitionId: string, expectedRevision: number, actorId?: string): Promise<{
394
- id: string;
395
- version: number;
396
- description: string | null;
397
- name: string;
398
- createdByUserId: string | null;
399
- createdAt: Date;
400
- reportDefinitionId: string;
401
- definitionRevision: number;
402
- snapshot: {
403
- parameters: Record<string, string | number | boolean | (string | number | boolean | null)[] | null>;
404
- widgets: {
405
- id: string;
406
- source: {
407
- kind: "preset";
408
- widgetId: string;
409
- version?: number | undefined;
410
- } | {
411
- kind: "custom";
412
- definition: {
413
- id: string;
414
- version: number;
415
- label: string;
416
- query: {
417
- dataset: {
418
- id: string;
419
- version?: number | undefined;
420
- };
421
- select: ({
422
- kind: "field";
423
- field: string;
424
- as?: string | undefined;
425
- } | {
426
- kind: "aggregate";
427
- operation: "minimum" | "maximum" | "count" | "countDistinct" | "sum" | "average";
428
- as: string;
429
- field?: string | undefined;
430
- })[];
431
- filters: {
432
- field: string;
433
- operator: "in" | "equal" | "notEqual" | "notIn" | "greaterThan" | "greaterThanOrEqual" | "lessThan" | "lessThanOrEqual" | "between" | "contains" | "isNull" | "isNotNull";
434
- value?: {
435
- kind: "literal";
436
- value: string | number | boolean | (string | number | boolean | null)[] | null;
437
- } | {
438
- kind: "parameter";
439
- name: string;
440
- } | undefined;
441
- }[];
442
- groupBy: {
443
- field: string;
444
- timeGrain?: "day" | "week" | "month" | "quarter" | "year" | undefined;
445
- }[];
446
- orderBy: {
447
- by: string;
448
- direction: "ascending" | "descending";
449
- }[];
450
- limit?: number | undefined;
451
- };
452
- visualization: {
453
- type: "kpi" | "table" | "line" | "bar" | "pie";
454
- options: Record<string, unknown>;
455
- };
456
- defaultSize: {
457
- width: number;
458
- height: number;
459
- };
460
- description?: string | undefined;
461
- minimumSize?: {
462
- width: number;
463
- height: number;
464
- } | undefined;
465
- maximumSize?: {
466
- width: number;
467
- height: number;
468
- } | undefined;
469
- };
470
- };
471
- layout: {
472
- width: number;
473
- height: number;
474
- x: number;
475
- y: number;
476
- };
477
- title?: string | undefined;
478
- }[];
479
- };
480
- }>;
481
- runVersion(db: PostgresJsDatabase, versionId: string, parameters: ReportParameters, context: {
390
+ /**
391
+ * Execute every widget in a report and return its tabular data, ready to be
392
+ * serialized to CSV / XLSX / PDF. Widgets that fail (unavailable, scope, or
393
+ * query error) become a section carrying the error message rather than
394
+ * aborting the whole export.
395
+ */
396
+ exportReport(db: PostgresJsDatabase, id: string, parameters: ReportParameters, context: {
482
397
  actorId?: string;
483
398
  grantedScopes: readonly string[];
484
399
  signal?: AbortSignal;
485
- }): Promise<{
486
- output: ReportRunOutput | null;
487
- parameters: Record<string, string | number | boolean | (string | number | boolean | null)[] | null>;
400
+ }): Promise<ReportExport | null>;
401
+ };
402
+ /** Presentation hints carried from a widget's visualization options into export. */
403
+ export interface ReportExportSectionFormat {
404
+ /** Currency values are stored in minor units (cents) and must be divided by 100. */
405
+ readonly minorUnit: boolean;
406
+ /** Column whose value holds the ISO currency code for each row. */
407
+ readonly currencyField?: string;
408
+ /** Fallback ISO currency when a row has no {@link currencyField}. */
409
+ readonly currency?: string;
410
+ }
411
+ /** A single widget's tabular data within an exported report. */
412
+ export interface ReportExportSection {
413
+ readonly title: string;
414
+ readonly columns: ReadonlyArray<{
488
415
  id: string;
489
- createdAt: Date;
490
- reportVersionId: string;
491
- status: "succeeded" | "failed" | "running";
492
- error: string | null;
493
- triggeredByUserId: string | null;
494
- startedAt: Date;
495
- completedAt: Date | null;
416
+ label: string;
417
+ valueType: string;
496
418
  }>;
497
- getRun(db: PostgresJsDatabase, id: string, grantedScopes: readonly string[]): Promise<{
498
- id: string;
499
- reportVersionId: string;
500
- status: "succeeded" | "failed" | "running";
501
- parameters: Record<string, string | number | boolean | (string | number | boolean | null)[] | null>;
502
- output: ReportRunOutput | null;
503
- error: string | null;
504
- triggeredByUserId: string | null;
505
- startedAt: Date;
506
- completedAt: Date | null;
507
- createdAt: Date;
508
- } | null>;
509
- };
419
+ readonly rows: ReadonlyArray<Record<string, unknown>>;
420
+ readonly format?: ReportExportSectionFormat;
421
+ readonly error?: string;
422
+ }
423
+ /** A report resolved to plain tabular sections for file export. */
424
+ export interface ReportExport {
425
+ readonly name: string;
426
+ readonly description: string | null;
427
+ readonly sections: readonly ReportExportSection[];
428
+ }
package/dist/service.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { and, desc, eq, sql } from "drizzle-orm";
2
- import { reportDefinitions, reportRuns, reportVersions, } from "./schema.js";
2
+ import { reportDefinitions } from "./schema.js";
3
3
  export class ReportDefinitionRevisionConflictError extends Error {
4
4
  constructor() {
5
5
  super("The report draft changed since it was loaded.");
@@ -12,12 +12,6 @@ export class ReportingRecordNotFoundError extends Error {
12
12
  this.name = "ReportingRecordNotFoundError";
13
13
  }
14
14
  }
15
- export class ReportDefinitionRetentionConflictError extends Error {
16
- constructor() {
17
- super("Reports with retained execution history cannot be deleted.");
18
- this.name = "ReportDefinitionRetentionConflictError";
19
- }
20
- }
21
15
  export function createReportingService(registry) {
22
16
  return {
23
17
  async list(db, input) {
@@ -83,177 +77,67 @@ export function createReportingService(registry) {
83
77
  throw new ReportDefinitionRevisionConflictError();
84
78
  },
85
79
  async remove(db, id) {
86
- return db.transaction(async (transaction) => {
87
- const [definition] = await transaction
88
- .select({ id: reportDefinitions.id })
89
- .from(reportDefinitions)
90
- .where(eq(reportDefinitions.id, id))
91
- .limit(1)
92
- .for("update");
93
- if (!definition)
94
- return false;
95
- await transaction
96
- .select({ id: reportVersions.id })
97
- .from(reportVersions)
98
- .where(eq(reportVersions.reportDefinitionId, id))
99
- .for("update");
100
- const [retainedRun] = await transaction
101
- .select({ id: reportRuns.id })
102
- .from(reportRuns)
103
- .innerJoin(reportVersions, eq(reportRuns.reportVersionId, reportVersions.id))
104
- .where(eq(reportVersions.reportDefinitionId, id))
105
- .limit(1);
106
- if (retainedRun)
107
- throw new ReportDefinitionRetentionConflictError();
108
- await transaction.delete(reportDefinitions).where(eq(reportDefinitions.id, id));
109
- return true;
110
- });
111
- },
112
- async createVersion(db, reportDefinitionId, expectedRevision, actorId) {
113
- return db.transaction(async (transaction) => {
114
- const [definition] = await transaction
115
- .select()
116
- .from(reportDefinitions)
117
- .where(eq(reportDefinitions.id, reportDefinitionId))
118
- .limit(1)
119
- .for("update");
120
- if (!definition)
121
- throw new ReportingRecordNotFoundError("Report definition");
122
- if (definition.revision !== expectedRevision)
123
- throw new ReportDefinitionRevisionConflictError();
124
- const [latest] = await transaction
125
- .select({ version: reportVersions.version })
126
- .from(reportVersions)
127
- .where(eq(reportVersions.reportDefinitionId, reportDefinitionId))
128
- .orderBy(desc(reportVersions.version))
129
- .limit(1);
130
- const [version] = await transaction
131
- .insert(reportVersions)
132
- .values({
133
- reportDefinitionId,
134
- version: (latest?.version ?? 0) + 1,
135
- name: definition.name,
136
- description: definition.description,
137
- definitionRevision: definition.revision,
138
- snapshot: registry.snapshotDraft(definition.draft),
139
- createdByUserId: actorId,
140
- })
141
- .returning();
142
- if (!version)
143
- throw new Error("Report version insert returned no row.");
144
- return version;
145
- });
80
+ const deleted = await db
81
+ .delete(reportDefinitions)
82
+ .where(eq(reportDefinitions.id, id))
83
+ .returning({ id: reportDefinitions.id });
84
+ return deleted.length > 0;
146
85
  },
147
- async runVersion(db, versionId, parameters, context) {
148
- const execution = await db.transaction(async (transaction) => {
149
- const [version] = await transaction
150
- .select()
151
- .from(reportVersions)
152
- .where(eq(reportVersions.id, versionId))
153
- .limit(1)
154
- .for("share");
155
- if (!version)
156
- throw new ReportingRecordNotFoundError("Report version");
157
- for (const resolved of registry.resolveDraft(version.snapshot, "edit")) {
158
- if (resolved.status === "missing" || !resolved.definition)
159
- continue;
160
- registry.validateQuery(resolved.definition.query, context.grantedScopes);
86
+ /**
87
+ * Execute every widget in a report and return its tabular data, ready to be
88
+ * serialized to CSV / XLSX / PDF. Widgets that fail (unavailable, scope, or
89
+ * query error) become a section carrying the error message rather than
90
+ * aborting the whole export.
91
+ */
92
+ async exportReport(db, id, parameters, context) {
93
+ const [definition] = await db
94
+ .select()
95
+ .from(reportDefinitions)
96
+ .where(eq(reportDefinitions.id, id))
97
+ .limit(1);
98
+ if (!definition)
99
+ return null;
100
+ const merged = { ...definition.draft.parameters, ...parameters };
101
+ const sections = [];
102
+ for (const resolved of registry.resolveDraft(definition.draft, "view")) {
103
+ const title = resolved.definition
104
+ ? (resolved.instance.title ?? resolved.definition.label)
105
+ : (resolved.instance.title ?? resolved.instance.id);
106
+ if (resolved.status === "missing" || !resolved.definition) {
107
+ sections.push({ title, columns: [], rows: [], error: resolved.missingReason });
108
+ continue;
109
+ }
110
+ try {
111
+ const result = await registry.executeQuery({
112
+ db,
113
+ actorId: context.actorId,
114
+ grantedScopes: context.grantedScopes,
115
+ query: resolved.definition.query,
116
+ parameters: merged,
117
+ signal: context.signal,
118
+ });
119
+ const options = resolved.definition.visualization.options ?? {};
120
+ sections.push({
121
+ title,
122
+ columns: result.columns,
123
+ rows: result.rows,
124
+ format: {
125
+ minorUnit: options.minorUnit === true,
126
+ currencyField: typeof options.currencyField === "string" ? options.currencyField : undefined,
127
+ currency: typeof options.currency === "string" ? options.currency : undefined,
128
+ },
129
+ });
130
+ }
131
+ catch (error) {
132
+ sections.push({
133
+ title,
134
+ columns: [],
135
+ rows: [],
136
+ error: error instanceof Error ? error.message : "Widget failed to export.",
137
+ });
161
138
  }
162
- const [run] = await transaction
163
- .insert(reportRuns)
164
- .values({ reportVersionId: versionId, parameters, triggeredByUserId: context.actorId })
165
- .returning();
166
- if (!run)
167
- throw new Error("Report run insert returned no row.");
168
- return { run, version };
169
- });
170
- const { run, version } = execution;
171
- try {
172
- const executionSignal = context.signal
173
- ? AbortSignal.any([context.signal, AbortSignal.timeout(60_000)])
174
- : AbortSignal.timeout(60_000);
175
- const output = await executeDraft(registry, db, version.snapshot, parameters, {
176
- ...context,
177
- signal: executionSignal,
178
- });
179
- const failed = output.widgets.some((widget) => widget.status === "failed");
180
- const [completed] = await db
181
- .update(reportRuns)
182
- .set({
183
- status: failed ? "failed" : "succeeded",
184
- output,
185
- error: failed ? "One or more widgets failed." : null,
186
- completedAt: new Date(),
187
- })
188
- .where(eq(reportRuns.id, run.id))
189
- .returning();
190
- return completed ?? run;
191
- }
192
- catch (error) {
193
- const message = error instanceof Error ? error.message : "Report execution failed.";
194
- const [failed] = await db
195
- .update(reportRuns)
196
- .set({ status: "failed", error: message, completedAt: new Date() })
197
- .where(eq(reportRuns.id, run.id))
198
- .returning();
199
- return failed ?? run;
200
- }
201
- },
202
- async getRun(db, id, grantedScopes) {
203
- const [run] = await db.select().from(reportRuns).where(eq(reportRuns.id, id)).limit(1);
204
- if (run) {
205
- const requiredScopes = [
206
- ...new Set(run.output?.widgets.flatMap((widget) => widget.requiredScopes ?? []) ?? []),
207
- ];
208
- registry.requireScopes(requiredScopes, grantedScopes);
209
139
  }
210
- return run ?? null;
140
+ return { name: definition.name, description: definition.description, sections };
211
141
  },
212
142
  };
213
143
  }
214
- async function executeDraft(registry, db, draft, runParameters, context) {
215
- const parameters = { ...draft.parameters, ...runParameters };
216
- const widgets = [];
217
- for (const resolved of registry.resolveDraft(draft, "edit")) {
218
- if (resolved.status === "missing" || !resolved.definition) {
219
- widgets.push({
220
- widgetInstanceId: resolved.instance.id,
221
- status: "missing",
222
- reason: resolved.missingReason ?? "Widget is unavailable.",
223
- });
224
- continue;
225
- }
226
- let provenance;
227
- try {
228
- const validation = registry.validateQuery(resolved.definition.query, context.grantedScopes);
229
- provenance = {
230
- datasetId: validation.dataset.definition.id,
231
- datasetVersion: validation.dataset.definition.version,
232
- requiredScopes: validation.requiredScopes,
233
- };
234
- const result = await registry.executeQuery({
235
- db,
236
- actorId: context.actorId,
237
- grantedScopes: context.grantedScopes,
238
- query: resolved.definition.query,
239
- parameters,
240
- signal: context.signal,
241
- });
242
- widgets.push({
243
- widgetInstanceId: resolved.instance.id,
244
- status: "succeeded",
245
- ...provenance,
246
- result,
247
- });
248
- }
249
- catch (error) {
250
- widgets.push({
251
- widgetInstanceId: resolved.instance.id,
252
- status: "failed",
253
- ...provenance,
254
- reason: error instanceof Error ? error.message : "Widget execution failed.",
255
- });
256
- }
257
- }
258
- return { widgets };
259
- }
@@ -0,0 +1,3 @@
1
+ DROP TABLE "report_runs";--> statement-breakpoint
2
+ DROP TABLE "report_versions";--> statement-breakpoint
3
+ DROP TYPE "public"."report_run_status";
@@ -8,6 +8,13 @@
8
8
  "when": 1784365200000,
9
9
  "tag": "20260718090000_reporting_baseline",
10
10
  "breakpoints": true
11
+ },
12
+ {
13
+ "idx": 1,
14
+ "version": "7",
15
+ "when": 1784480400000,
16
+ "tag": "20260719170000_drop_report_versioning",
17
+ "breakpoints": true
11
18
  }
12
19
  ]
13
20
  }