@happyvertical/smrt-reports 0.43.0 → 0.43.1

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/AGENTS.md CHANGED
@@ -15,6 +15,7 @@ Materialized aggregate report models for SMRT.
15
15
  | scheduler | Cron schedule runner, durable refresh job enqueueing, and `onChange` interceptor registration |
16
16
  | adapter | Transport-neutral report descriptor, canonical materialized-row reads, and stable `id` row identity |
17
17
  | lifecycle | Tenant-safe freshness, run, lock, failure, and manual refresh preview/apply surfaces |
18
+ | views | Policy-revalidated saved views, snapshot-bound exports, bounded job handoff, and artifact metadata validation |
18
19
 
19
20
  ## Adapter contract
20
21
 
@@ -46,6 +47,19 @@ Materialized aggregate report models for SMRT.
46
47
  audit, and queueing through an application action host. Only a registered
47
48
  `SmrtReportCollection` may synchronously refresh stale reads, when its TTL is
48
49
  positive and the report is not manual.
50
+ - `views` accepts no authority. Persisted views must be normalized again through
51
+ the current descriptor before restoring them, so changed column policy or a
52
+ changed report definition fails closed. Treat unversioned persisted layouts as
53
+ legacy v0 and migrate them to v1 before normalization; unknown versions must
54
+ fail rather than being inferred. Export snapshots retain the canonical
55
+ query fingerprint, projection, sort, as-of/freshness state, exact row count,
56
+ and the fixed principal/tenant/report-definition/field-policy inheritance
57
+ contract. A snapshot needs an application-host-issued opaque binding and an
58
+ explicit offset-page read plan: workers advance it from offset zero instead
59
+ of reusing a visible page. Preview, apply, worker, and artifact-serving
60
+ boundaries must call the validators, including the host's immutable-snapshot
61
+ assertion; application hosts own authorization, audit records, durable
62
+ storage, download tokens, and queue execution.
49
63
 
50
64
  ## Conventions
51
65
 
package/README.md CHANGED
@@ -221,6 +221,44 @@ report-refresh job is queued. Only a registered `SmrtReportCollection` can
221
221
  synchronously refresh a stale read, and only when its TTL policy is positive and
222
222
  not manual.
223
223
 
224
+ ### Saved views and snapshot exports
225
+
226
+ `normalizeReportSavedView()` and `restoreReportSavedView()` provide the
227
+ serializable view boundary. A storage host owns the saved view's tenant and
228
+ owner; on every restore it must pass the stored payload through the current
229
+ descriptor. That reapplies field, projection, sorting, grouping, and definition
230
+ policy, so a stale view cannot reveal a field that is no longer allowed.
231
+ `migrateReportSavedView()` upgrades the original unversioned (or explicit v0)
232
+ layout to v1 before that current-policy validation; unsupported future versions
233
+ fail clearly rather than being guessed.
234
+
235
+ Build an export from a completed materialized-row read with
236
+ `createReportExportSnapshot()`, supplying an opaque binding from the
237
+ application's immutable materialization-snapshot host, then call
238
+ `createReportExportRequest()`. The snapshot fixes the canonical query
239
+ fingerprint, normalized projection and sort, exact row count, `asOf`,
240
+ `refreshedAt`, stale state, and definition fingerprint. Every request also
241
+ contains a deterministic offset-page read plan; a renderer begins at offset zero
242
+ and advances by its validated page size until the exact bounded row count is
243
+ reached. Use `createReportExportPageRequest()` for each page; it preserves the
244
+ frozen query semantics while replacing only visible pagination.
245
+
246
+ Every request is bounded by rows, bytes, and deadline; exports over the
247
+ foreground row limit become an authority-free background handoff. Use
248
+ `previewReportExport()` and `applyReportExport()` with the same application
249
+ action host for human and agent callers. The host authorizes and audits the
250
+ fixed `reports.export` action; exports containing personal, sensitive, or secret
251
+ columns require explicit confirmation.
252
+
253
+ Preview, apply, and every worker call `validateReportExportExecution()`, which
254
+ requires the host to prove that the opaque binding still resolves to the exact
255
+ immutable materialization snapshot under its current principal, tenant, report
256
+ definition, and field policy. If it cannot, the operation fails rather than
257
+ relabelling newer rows with an old `asOf`. Artifact metadata has no URL or
258
+ download token; before serving it, call `validateReportExportArtifact()` to
259
+ reject expiry, definition drift, and out-of-bounds progress, then apply the
260
+ host's current authorization and snapshot validation again.
261
+
224
262
  ## Development
225
263
 
226
264
  ```bash
package/dist/index.d.ts CHANGED
@@ -8,9 +8,11 @@ import { buildAggregate } from '@happyvertical/sql';
8
8
  import { DatabaseInterface } from '@happyvertical/sql';
9
9
  import { DataQueryFieldDescriptor } from '@happyvertical/smrt-types';
10
10
  import { DataQueryFilter } from '@happyvertical/smrt-types';
11
+ import { DataQueryFreshness } from '@happyvertical/smrt-types';
11
12
  import { DataQueryRequest } from '@happyvertical/smrt-types';
12
13
  import { DataQueryResult } from '@happyvertical/smrt-types';
13
14
  import { DataQuerySchema } from '@happyvertical/smrt-types';
15
+ import { DataQuerySort } from '@happyvertical/smrt-types';
14
16
  import { EventEmitter } from 'node:events';
15
17
  import { SmrtCollection } from '@happyvertical/smrt-core';
16
18
  import { SmrtJob } from '@happyvertical/smrt-jobs';
@@ -33,11 +35,33 @@ export { AggregateSelectExpr }
33
35
 
34
36
  export { AggregateSpec }
35
37
 
38
+ export declare type AppliedReportExport = {
39
+ phase: 'apply';
40
+ execution: 'stream';
41
+ status: 'ready';
42
+ request: ReportExportRequest;
43
+ } | {
44
+ phase: 'apply';
45
+ execution: 'background';
46
+ status: 'queued';
47
+ taskId: string;
48
+ request: ReportExportRequest;
49
+ };
50
+
36
51
  export declare interface AppliedReportRefresh {
37
52
  phase: 'apply';
38
53
  job: ReportRefreshJobHandle;
39
54
  }
40
55
 
56
+ /**
57
+ * Queue large exports without embedding rows in a browser/agent response.
58
+ * A host must verify current principal, tenant, report definition, and field
59
+ * policy again before it renders or serves the artifact.
60
+ */
61
+ export declare function applyReportExport(descriptor: ReportAdapterDescriptor, request: ReportExportRequest, host: ReportExportActionHost, options?: {
62
+ confirmed?: boolean;
63
+ }): Promise<AppliedReportExport>;
64
+
41
65
  /** Queue a manual refresh only after the action host authorizes and audits it. */
42
66
  export declare function applyReportRefresh(reportCtor: ReportCtor, options: ApplyReportRefreshOptions): Promise<AppliedReportRefresh>;
43
67
 
@@ -85,6 +109,27 @@ export declare function count(options: {
85
109
  distinct?: boolean;
86
110
  }): PropertyDecorator;
87
111
 
112
+ /**
113
+ * Build one bounded page of the immutable export read. The host applies the
114
+ * opaque snapshot binding separately; this function preserves the frozen
115
+ * projection, predicate, and deterministic sort while replacing only visible
116
+ * pagination. A zero-row export therefore has no pages to execute.
117
+ */
118
+ export declare function createReportExportPageRequest(descriptor: ReportAdapterDescriptor, request: ReportExportRequest, offset: number): DataQueryRequest;
119
+
120
+ /**
121
+ * Build one common request for both agent and human export clients. The
122
+ * resulting request carries no actor, tenant, storage location, or token.
123
+ */
124
+ export declare function createReportExportRequest(descriptor: ReportAdapterDescriptor, snapshot: ReportExportSnapshot, value: unknown): ReportExportRequest;
125
+
126
+ /**
127
+ * Freeze a previously executed materialized read for export. The caller must
128
+ * supply the exact request and a result that exposes lifecycle freshness; an
129
+ * export never invents an as-of value or accepts a caller-supplied tenant.
130
+ */
131
+ export declare function createReportExportSnapshot(descriptor: ReportAdapterDescriptor, input: unknown, result: Pick<DataQueryResult, 'queryFingerprint' | 'identityField' | 'total' | 'freshness' | 'truncated'> & Pick<ReportDataQueryResult, 'reportLifecycle'>, binding: unknown): ReportExportSnapshot;
132
+
88
133
  export declare const day: (sourceColumn: string) => PropertyDecorator;
89
134
 
90
135
  export declare function enqueueReportRefresh(options: EnqueueReportRefreshOptions): Promise<SmrtJob>;
@@ -126,10 +171,19 @@ export declare function groupBy(sourceColumn?: string): PropertyDecorator;
126
171
 
127
172
  export declare const hour: (sourceColumn: string) => PropertyDecorator;
128
173
 
174
+ declare const INHERITED_REPORT_SCOPE: readonly ["principal", "tenant", "report-definition", "field-policy"];
175
+
129
176
  export declare const max: (column?: string, options?: {
130
177
  distinct?: boolean;
131
178
  }) => PropertyDecorator;
132
179
 
180
+ /**
181
+ * Version 0 is the original unversioned persisted layout shape. It contains
182
+ * the same allowlisted fields as v1, so the migration only records the schema
183
+ * version; current descriptor normalization still revalidates every field.
184
+ */
185
+ export declare function migrateReportSavedView(value: unknown): SavedViewInput;
186
+
133
187
  export declare const min: (column?: string, options?: {
134
188
  distinct?: boolean;
135
189
  }) => PropertyDecorator;
@@ -138,6 +192,15 @@ export declare const minute: (sourceColumn: string) => PropertyDecorator;
138
192
 
139
193
  export declare const month: (sourceColumn: string) => PropertyDecorator;
140
194
 
195
+ /**
196
+ * Normalize a view both when it is saved and when it is restored. Re-running
197
+ * this against the live descriptor is what prevents a former field/action
198
+ * grant from surviving a policy or definition change.
199
+ */
200
+ export declare function normalizeReportSavedView(descriptor: ReportAdapterDescriptor, value: unknown): ReportSavedView;
201
+
202
+ export declare function previewReportExport(descriptor: ReportAdapterDescriptor, request: ReportExportRequest, host: ReportExportActionHost): Promise<ReportExportPreview>;
203
+
141
204
  /** Preview only declares a separately authorized and audited refresh request. */
142
205
  export declare function previewReportRefresh(reportCtor: ReportCtor, options: PreviewReportRefreshOptions): Promise<ReportRefreshPreview>;
143
206
 
@@ -222,6 +285,18 @@ export declare interface ReportAggregateFieldMetadata {
222
285
 
223
286
  export declare type ReportAggregateFn = AggregateFunction;
224
287
 
288
+ /**
289
+ * An authority-free job handoff. The queue must re-enter the bound principal
290
+ * and tenant, resolve the current report definition/field policy, and verify
291
+ * this snapshot before materializing an artifact.
292
+ */
293
+ export declare interface ReportBackgroundExportTask {
294
+ version: 1;
295
+ execution: 'background';
296
+ request: ReportExportRequest;
297
+ inherits: Array<(typeof INHERITED_REPORT_SCOPE)[number]>;
298
+ }
299
+
225
300
  /** Background execution is possible only through an application-owned queue host. */
226
301
  export declare interface ReportBackgroundQueryOptions extends ReportQueryCommonOptions {
227
302
  execution: 'background';
@@ -396,6 +471,12 @@ export declare interface ReportDefinition {
396
471
  refresh?: ReportRefreshConfig;
397
472
  }
398
473
 
474
+ /**
475
+ * A change to report fields, their capabilities, schema, scope, or source
476
+ * invalidates a stored view/export snapshot rather than silently widening it.
477
+ */
478
+ export declare function reportDefinitionFingerprint(descriptor: ReportAdapterDescriptor): string;
479
+
399
480
  export declare interface ReportDrilldownConstraint {
400
481
  id: string;
401
482
  sourceColumn: string;
@@ -436,6 +517,148 @@ export declare interface ReportDrilldownQuery {
436
517
  inherits: Array<'principal' | 'tenant' | 'report-definition' | 'field-policy'>;
437
518
  }
438
519
 
520
+ export declare interface ReportExportActionContext {
521
+ phase: 'preview' | 'apply';
522
+ reportClassName: string;
523
+ resourceId: string;
524
+ requiredPermission: string;
525
+ confirmationRequired: boolean;
526
+ execution: 'stream' | 'background';
527
+ queryFingerprint: string;
528
+ definitionFingerprint: string;
529
+ snapshotId: string;
530
+ asOf: string;
531
+ pageSize: number;
532
+ }
533
+
534
+ /** The same host is used by human and agent callers. */
535
+ export declare interface ReportExportActionHost {
536
+ /**
537
+ * Prove that this opaque binding still resolves to the exact immutable
538
+ * materialization snapshot under the host's current principal and tenant.
539
+ * The background worker must call `validateReportExportExecution()` again
540
+ * before reading or serving any rows.
541
+ */
542
+ assertSnapshot(context: ReportExportSnapshotContext): Promise<void> | void;
543
+ authorize(context: ReportExportActionContext): Promise<void> | void;
544
+ audit(context: ReportExportActionContext): Promise<void> | void;
545
+ enqueue?(task: ReportBackgroundExportTask): Promise<{
546
+ taskId: string;
547
+ }> | {
548
+ taskId: string;
549
+ };
550
+ }
551
+
552
+ /**
553
+ * Artifact metadata is deliberately URL- and token-free. An application-owned
554
+ * serving boundary must authorize every download using the caller's current
555
+ * principal and tenant before it reveals any artifact location.
556
+ */
557
+ export declare interface ReportExportArtifact {
558
+ id: string;
559
+ request: ReportExportRequest;
560
+ progress: ReportExportProgress;
561
+ expiresAt: string;
562
+ }
563
+
564
+ export declare type ReportExportFormat = 'csv' | 'json';
565
+
566
+ /** Every export is bounded before it is streamed or delegated to a worker. */
567
+ export declare interface ReportExportLimits {
568
+ maxRows: number;
569
+ maxBytes: number;
570
+ deadlineMs: number;
571
+ /** Exports above this exact row count must use the application queue. */
572
+ foregroundRowLimit: number;
573
+ }
574
+
575
+ export declare interface ReportExportPreview {
576
+ phase: 'preview';
577
+ action: ReportExportActionContext;
578
+ request: ReportExportRequest;
579
+ }
580
+
581
+ export declare interface ReportExportProgress {
582
+ state: 'queued' | 'running' | 'completed' | 'failed';
583
+ rowCount: number;
584
+ byteCount: number;
585
+ truncated: boolean;
586
+ }
587
+
588
+ /**
589
+ * A deterministic offset-page plan over the immutable host snapshot. A worker
590
+ * starts at offset zero and advances by `page.limit` until `rowCount` rows have
591
+ * been produced or the byte/deadline limits are reached.
592
+ */
593
+ export declare interface ReportExportReadPlan {
594
+ snapshotId: string;
595
+ queryFingerprint: string;
596
+ asOf: string;
597
+ page: {
598
+ kind: 'offset';
599
+ offset: 0;
600
+ limit: number;
601
+ };
602
+ }
603
+
604
+ export declare interface ReportExportRequest {
605
+ version: 1;
606
+ format: ReportExportFormat;
607
+ execution: 'stream' | 'background';
608
+ snapshot: ReportExportSnapshot;
609
+ limits: ReportExportLimits;
610
+ read: ReportExportReadPlan;
611
+ rowCount: number;
612
+ truncated: boolean;
613
+ action: {
614
+ id: 'export';
615
+ requiredPermission: string;
616
+ auditRequired: true;
617
+ confirmationRequired: boolean;
618
+ };
619
+ }
620
+
621
+ export declare interface ReportExportSnapshot {
622
+ version: 1;
623
+ resourceId: string;
624
+ reportClassName: string;
625
+ definitionFingerprint: string;
626
+ binding: ReportExportSnapshotBinding;
627
+ request: DataQueryRequest;
628
+ queryFingerprint: string;
629
+ projection: string[];
630
+ sort: DataQuerySort[];
631
+ freshness: DataQueryFreshness;
632
+ snapshot: {
633
+ asOf: string;
634
+ refreshedAt?: string;
635
+ stale: boolean;
636
+ };
637
+ total: {
638
+ kind: 'exact';
639
+ value: number;
640
+ };
641
+ inherits: Array<(typeof INHERITED_REPORT_SCOPE)[number]>;
642
+ }
643
+
644
+ /**
645
+ * Opaque identity of a host-captured materialization snapshot. The application
646
+ * creates and verifies it while bound to its principal and tenant; callers
647
+ * cannot turn an `asOf` label into a snapshot by supplying this id alone.
648
+ */
649
+ export declare interface ReportExportSnapshotBinding {
650
+ id: string;
651
+ }
652
+
653
+ export declare interface ReportExportSnapshotContext {
654
+ bindingId: string;
655
+ resourceId: string;
656
+ reportClassName: string;
657
+ definitionFingerprint: string;
658
+ queryFingerprint: string;
659
+ asOf: string;
660
+ }
661
+
439
662
  export declare interface ReportFieldDefinition {
440
663
  fieldName: string;
441
664
  columnName?: string;
@@ -714,6 +937,42 @@ export declare function reportRowIdentity(row: Record<string, any>, definition:
714
937
 
715
938
  declare type ReportRunStatus = 'running' | 'success' | 'failed' | 'skipped';
716
939
 
940
+ /**
941
+ * A normalized, policy-neutral stored view. The storage host associates it
942
+ * with its owner and tenant; neither identity is accepted here.
943
+ */
944
+ export declare interface ReportSavedView {
945
+ version: 1;
946
+ id: string;
947
+ title: string;
948
+ resourceId: string;
949
+ reportClassName: string;
950
+ definitionFingerprint: string;
951
+ query: DataQueryRequest;
952
+ columns: ReportSavedViewColumn[];
953
+ grouping?: ReportSavedViewGrouping;
954
+ display?: ReportSavedViewDisplay;
955
+ }
956
+
957
+ /** A view's column order is its array order; ids are always adapter-defined. */
958
+ export declare interface ReportSavedViewColumn {
959
+ id: string;
960
+ width?: number;
961
+ pinned?: ReportSavedViewPinnedSide;
962
+ }
963
+
964
+ export declare interface ReportSavedViewDisplay {
965
+ density: 'compact' | 'comfortable';
966
+ showTotals?: boolean;
967
+ }
968
+
969
+ export declare interface ReportSavedViewGrouping {
970
+ fields: string[];
971
+ expanded?: boolean;
972
+ }
973
+
974
+ export declare type ReportSavedViewPinnedSide = 'start' | 'end';
975
+
717
976
  export declare interface ReportScheduleInfo {
718
977
  id: string;
719
978
  reportClass: string;
@@ -759,8 +1018,28 @@ declare type ReportScheduleStatus = 'active' | 'paused' | 'disabled' | 'error';
759
1018
 
760
1019
  export declare type ReportSource = string | (new (...args: any[]) => SmrtObject) | (abstract new (...args: any[]) => SmrtObject);
761
1020
 
1021
+ export declare class ReportSurfaceValidationError extends Error {
1022
+ constructor(message: string);
1023
+ }
1024
+
762
1025
  export declare type ReportTimeBucketUnit = AggregateTimeBucketUnit;
763
1026
 
1027
+ /** Restore through current policy rather than trusting a stored JSON record. */
1028
+ export declare function restoreReportSavedView(descriptor: ReportAdapterDescriptor, savedView: ReportSavedView): ReportSavedView;
1029
+
1030
+ declare interface SavedViewInput {
1031
+ version?: unknown;
1032
+ id: unknown;
1033
+ title: unknown;
1034
+ resourceId?: unknown;
1035
+ reportClassName?: unknown;
1036
+ definitionFingerprint?: unknown;
1037
+ query: unknown;
1038
+ columns?: unknown;
1039
+ grouping?: unknown;
1040
+ display?: unknown;
1041
+ }
1042
+
764
1043
  export declare function scopeKeyForTenant(tenantId: string | null | undefined): string;
765
1044
 
766
1045
  export declare class SmrtReport extends SmrtObject {
@@ -879,6 +1158,29 @@ export declare const sum: (column?: string, options?: {
879
1158
  distinct?: boolean;
880
1159
  }) => PropertyDecorator;
881
1160
 
1161
+ /**
1162
+ * Validate artifact metadata before a host serves it. Download locations and
1163
+ * tokens stay application-private; expired artifacts fail before that boundary.
1164
+ */
1165
+ export declare function validateReportExportArtifact(descriptor: ReportAdapterDescriptor, artifact: ReportExportArtifact, now?: Date): ReportExportArtifact;
1166
+
1167
+ /**
1168
+ * Revalidate the request and prove the opaque snapshot binding through the
1169
+ * application host. Preview, apply, and every background worker use this same
1170
+ * operation before reading or serving export data.
1171
+ */
1172
+ export declare function validateReportExportExecution(descriptor: ReportAdapterDescriptor, request: ReportExportRequest, host: Pick<ReportExportActionHost, 'assertSnapshot'>): Promise<ReportExportRequest>;
1173
+
1174
+ /**
1175
+ * Recreate the deterministic request from its current descriptor and frozen
1176
+ * snapshot. Preview, apply, queue, and serving hosts call this instead of
1177
+ * trusting browser- or agent-supplied action metadata.
1178
+ */
1179
+ export declare function validateReportExportRequest(descriptor: ReportAdapterDescriptor, request: ReportExportRequest): ReportExportRequest;
1180
+
1181
+ /** Reject definitions/policies that changed after a snapshot was frozen. */
1182
+ export declare function verifyReportExportSnapshot(descriptor: ReportAdapterDescriptor, snapshot: ReportExportSnapshot): ReportExportSnapshot;
1183
+
882
1184
  export declare const week: (sourceColumn: string) => PropertyDecorator;
883
1185
 
884
1186
  export declare const year: (sourceColumn: string) => PropertyDecorator;