@happyvertical/smrt-reports 0.42.6 → 0.42.7

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
@@ -13,6 +13,39 @@ Materialized aggregate report models for SMRT.
13
13
  | refresh | Rebuild and incremental refresh engine with run tracking, watermarks, locks, and tenant scoping |
14
14
  | state | Internal `_smrt_report_*` system models for runs, watermarks, locks, schedules, and refresh tasks |
15
15
  | scheduler | Cron schedule runner, durable refresh job enqueueing, and `onChange` interceptor registration |
16
+ | adapter | Transport-neutral report descriptor, canonical materialized-row reads, and stable `id` row identity |
17
+ | lifecycle | Tenant-safe freshness, run, lock, failure, and manual refresh preview/apply surfaces |
18
+
19
+ ## Adapter contract
20
+
21
+ - `buildReportAdapterDescriptor()` returns deterministic, serializable metadata
22
+ for a report surface: a stable resource id, typed persisted report columns,
23
+ the canonical `DataQuerySchema`, and UI-neutral DataTable hints. It must not
24
+ import `smrt-ui` or expose a report-domain class to the consumer.
25
+ - `queryReportMaterializedRows()` owns only the bounded read slice for rows that
26
+ are already materialized. It supports projection, offset/limit paging,
27
+ validated filters, deterministic multi-sort with an `id` tie-breaker, exact
28
+ totals, and dimension facets. At source-query compilation,
29
+ dimension and bucket filters compile to `WHERE`, aggregate-measure filters
30
+ compile to `HAVING`, and mixed `OR`/`NOT` filter scopes fail closed.
31
+ - `id` is the only row identity. It must be a non-empty persisted string and is
32
+ never replaced by a display index or page position.
33
+ - The descriptor is an exposure boundary. Sensitive/secret fields, fields with
34
+ `readPermission`, and transient, system, or non-column fields fail closed and
35
+ do not become public columns when no principal is available.
36
+ - `tenantScoped`/`tenantField` reflect actual registered tenant metadata. A
37
+ `tenantScope` option only contributes to the stable resource id; it is not
38
+ authorization. The default query path resolves the registered collection via
39
+ `ObjectRegistry`, so normal collection tenancy interceptors apply. An injected
40
+ collection is application-owned and must preserve the same boundary.
41
+ - `refresh` is a declaration, not execution. It describes configured mode,
42
+ triggers, positive-TTL stale-read behavior, and a permissioned/audited action
43
+ with preview/apply phases. The adapter remains read-only, while
44
+ `getReportLifecycle()` provides an explicit, tenant-safe lifecycle snapshot
45
+ and `previewReportRefresh()` / `applyReportRefresh()` delegate authorization,
46
+ audit, and queueing through an application action host. Only a registered
47
+ `SmrtReportCollection` may synchronously refresh stale reads, when its TTL is
48
+ positive and the report is not manual.
16
49
 
17
50
  ## Conventions
18
51
 
package/README.md CHANGED
@@ -101,6 +101,126 @@ helpers:
101
101
  @happyvertical/smrt-reports/aggregate
102
102
  ```
103
103
 
104
+ ## Report adapter
105
+
106
+ The package also exports a transport-neutral adapter for report surfaces:
107
+
108
+ ```ts
109
+ import {
110
+ buildReportAdapterDescriptor,
111
+ buildReportDrilldownQuery,
112
+ queryReportMaterializedRows,
113
+ reportMaterializedRowKey,
114
+ splitReportFilterScopes,
115
+ } from '@happyvertical/smrt-reports';
116
+
117
+ const descriptor = await buildReportAdapterDescriptor(MonthlyRevenue, {
118
+ tenantScope: 'current',
119
+ });
120
+ const reports = await MonthlyRevenueCollection.create({ db: 'app.db' });
121
+ const result = await queryReportMaterializedRows(
122
+ MonthlyRevenue,
123
+ {
124
+ version: 1,
125
+ requestId: 'monthly-revenue-first-page',
126
+ mode: 'rows',
127
+ projection: ['id', 'customer_id', 'revenue'],
128
+ filter: {
129
+ kind: 'all',
130
+ filters: [
131
+ {
132
+ kind: 'condition',
133
+ field: 'customer_id',
134
+ operator: 'eq',
135
+ value: 'customer-42',
136
+ },
137
+ {
138
+ kind: 'condition',
139
+ field: 'revenue',
140
+ operator: 'gte',
141
+ value: 1000,
142
+ },
143
+ ],
144
+ },
145
+ page: { kind: 'offset', offset: 0, limit: 25 },
146
+ sort: [{ field: 'revenue', direction: 'desc' }],
147
+ },
148
+ { collection: reports },
149
+ );
150
+ const rowKey = reportMaterializedRowKey(result.rows[0]);
151
+ const drilldown = await buildReportDrilldownQuery(MonthlyRevenue, result.rows[0]);
152
+ ```
153
+
154
+ `ReportAdapterDescriptor` is deterministic JSON with a stable resource id,
155
+ `id` as the identity field, typed report columns, a canonical `DataQuerySchema`,
156
+ and structural DataTable hints. The hints are deliberately not a `smrt-ui`
157
+ dependency. Consumers may map the descriptor to their own presentation layer.
158
+ The adapter exposes only persisted report columns and the primary key: transient,
159
+ system, and non-column fields are not surfaced. Sensitive/secret fields and
160
+ fields with a `readPermission` are excluded without a principal, so the
161
+ descriptor fails closed.
162
+
163
+ The `dataTable.columns` entries carry neutral `headerPath`, `valueFormat`,
164
+ alignment, role, and responsive hints. Grouping fields, time buckets, and
165
+ aggregate measures receive deterministic multi-level header ancestry; a
166
+ consumer can override any column by stable id with
167
+ `buildReportAdapterDescriptor(..., { dataTable: { columns: { ... } } })`.
168
+ `valueFormat` is an instruction for the rendering boundary only: rows returned
169
+ by `queryReportMaterializedRows()` retain their raw JSON-safe values for
170
+ sorting, exports, and agents. Use `dataTable.structuralRows` for a computed
171
+ summary, subtotal, aggregate, or footer. Each is marked `selection: 'excluded'`
172
+ and `actions: 'excluded'`, and must be passed to the consumer table's structural
173
+ row surface rather than its selectable data rows.
174
+
175
+ `queryReportMaterializedRows()` is the bounded read slice for already-materialized
176
+ rows. It supports projection, offset/limit paging (default limit 50),
177
+ deterministic multi-sort (with `id` as the final tie-break), typed filters, and
178
+ database-backed dimension facets. A descriptor marks group/time fields as
179
+ `filterScope: 'where'` and aggregate measures as `filterScope: 'having'`; use
180
+ `splitReportFilterScopes()` when constructing a live source query. An AND may
181
+ combine the two scopes, but an OR or NOT cannot mix them, because moving either
182
+ side across a source `WHERE`/`HAVING` boundary would change its meaning.
183
+
184
+ The materialized collection executes the normalized, allowlisted predicate as
185
+ parameterized SQL and applies it identically to rows, totals, and facets. It
186
+ never accepts raw SQL, source field paths, tenant ids, or principal ids.
187
+ The descriptor's `queryExecution` contract declares three delivery choices:
188
+ `visible` (the default) returns rows for an already-authorized surface,
189
+ `silent` returns the same bounded result while making no visible-surface change,
190
+ and `background` delegates a normalized, authority-free task to the application's
191
+ `enqueueBackgroundQuery` host. A background result is only a queue handle, never
192
+ materialized rows. The host retains the authenticated principal, tenant, report
193
+ definition, field policy, database, and eventual job execution; none can be
194
+ supplied in a query request or background task.
195
+ `buildReportDrilldownQuery()` carries only the row's declared groups/buckets
196
+ and a fixed inheritance contract for the current principal, tenant, report
197
+ definition, and field policy; an authenticated source adapter must enforce that
198
+ contract before it reads source records. Time buckets remain declarative so the
199
+ source adapter keeps the report's database/timezone semantics. Every returned
200
+ row must have a non-empty string `id`; use that value, never a display or page
201
+ index, for row identity.
202
+
203
+ When no collection is injected, reads resolve the registered report collection
204
+ through `ObjectRegistry`, allowing normal s-m-r-t collection interceptors to enforce
205
+ tenant filtering. `tenantScoped` and `tenantField` in the descriptor are true
206
+ only when the report is actually registered with tenant metadata; a scope string
207
+ alone is not an authority boundary. An injected collection is application-owned
208
+ and must provide the equivalent tenant boundary. Raw aggregate refreshes still
209
+ need explicit tenant predicates as described above.
210
+
211
+ The descriptor's `refresh` section declares mode, triggers, stale-read behavior,
212
+ and a permissioned, audited `refresh` action with `preview` and `apply` phases.
213
+ The adapter itself stays read-only. Call `getReportLifecycle()` for a tenant-safe
214
+ snapshot of current, stale, refreshing, lock-skipped, or failed materialization
215
+ state; it redacts lock owners, raw errors, and tenant-fanout identifiers. Pass a
216
+ `lifecycle` option to `queryReportMaterializedRows()` only when a consumer needs
217
+ that context; the result then distinguishes a current, stale, or read-triggered
218
+ refresh. `previewReportRefresh()` and `applyReportRefresh()` require an
219
+ application action host to authorize and audit the caller before a durable
220
+ report-refresh job is queued. Only a registered `SmrtReportCollection` can
221
+ synchronously refresh a stale read, and only when its TTL policy is positive and
222
+ not manual.
223
+
104
224
  ## Development
105
225
 
106
226
  ```bash