@happyvertical/smrt-reports 0.56.0 → 0.56.2
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/README.md +2 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -1
- package/dist/index.js.map +1 -1
- package/dist/manifest.json +53 -6
- package/dist/materialized-host.d.ts +72 -0
- package/dist/materialized-host.d.ts.map +1 -0
- package/dist/smrt-knowledge.json +5 -5
- package/dist/state.js +1 -1
- package/dist/state.js.map +1 -1
- package/dist/svelte/MaterializedReportTable.svelte +104 -0
- package/dist/svelte/MaterializedReportTable.svelte.d.ts +14 -0
- package/dist/svelte/MaterializedReportTable.svelte.d.ts.map +1 -0
- package/dist/svelte/index.d.ts +2 -0
- package/dist/svelte/index.d.ts.map +1 -1
- package/dist/svelte/index.js +1 -0
- package/dist/svelte/materialized.d.ts +19 -0
- package/dist/svelte/materialized.d.ts.map +1 -0
- package/dist/svelte/materialized.js +1 -0
- package/package.json +10 -10
package/dist/manifest.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"version": "1.0.0",
|
|
3
3
|
"timestamp": 0,
|
|
4
4
|
"packageName": "@happyvertical/smrt-reports",
|
|
5
|
-
"packageVersion": "0.56.
|
|
5
|
+
"packageVersion": "0.56.2",
|
|
6
6
|
"objects": {
|
|
7
7
|
"@happyvertical/smrt-reports:SmrtReport": {
|
|
8
8
|
"name": "smrtreport",
|
|
@@ -441,7 +441,26 @@
|
|
|
441
441
|
"fields": {},
|
|
442
442
|
"methods": {},
|
|
443
443
|
"decoratorConfig": {
|
|
444
|
-
"tableName": "runtime_reports"
|
|
444
|
+
"tableName": "runtime_reports",
|
|
445
|
+
"api": {
|
|
446
|
+
"include": [
|
|
447
|
+
"list",
|
|
448
|
+
"get",
|
|
449
|
+
"create"
|
|
450
|
+
]
|
|
451
|
+
},
|
|
452
|
+
"mcp": {
|
|
453
|
+
"include": [
|
|
454
|
+
"list",
|
|
455
|
+
"get"
|
|
456
|
+
]
|
|
457
|
+
},
|
|
458
|
+
"cli": {
|
|
459
|
+
"include": [
|
|
460
|
+
"list",
|
|
461
|
+
"get"
|
|
462
|
+
]
|
|
463
|
+
}
|
|
445
464
|
},
|
|
446
465
|
"extends": "SmrtCollection",
|
|
447
466
|
"extendsTypeArg": "RuntimeReport",
|
|
@@ -1248,7 +1267,14 @@
|
|
|
1248
1267
|
"decoratorConfig": {
|
|
1249
1268
|
"tableName": "_smrt_report_runs",
|
|
1250
1269
|
"api": false,
|
|
1251
|
-
"mcp": false
|
|
1270
|
+
"mcp": false,
|
|
1271
|
+
"cli": {
|
|
1272
|
+
"include": [
|
|
1273
|
+
"list",
|
|
1274
|
+
"get"
|
|
1275
|
+
],
|
|
1276
|
+
"skipApiCheck": true
|
|
1277
|
+
}
|
|
1252
1278
|
},
|
|
1253
1279
|
"extends": "SmrtCollection",
|
|
1254
1280
|
"extendsTypeArg": "SmrtReportRun",
|
|
@@ -1608,7 +1634,14 @@
|
|
|
1608
1634
|
"decoratorConfig": {
|
|
1609
1635
|
"tableName": "_smrt_report_watermarks",
|
|
1610
1636
|
"api": false,
|
|
1611
|
-
"mcp": false
|
|
1637
|
+
"mcp": false,
|
|
1638
|
+
"cli": {
|
|
1639
|
+
"include": [
|
|
1640
|
+
"list",
|
|
1641
|
+
"get"
|
|
1642
|
+
],
|
|
1643
|
+
"skipApiCheck": true
|
|
1644
|
+
}
|
|
1612
1645
|
},
|
|
1613
1646
|
"extends": "SmrtCollection",
|
|
1614
1647
|
"extendsTypeArg": "SmrtReportWatermark",
|
|
@@ -1921,7 +1954,14 @@
|
|
|
1921
1954
|
"decoratorConfig": {
|
|
1922
1955
|
"tableName": "_smrt_report_locks",
|
|
1923
1956
|
"api": false,
|
|
1924
|
-
"mcp": false
|
|
1957
|
+
"mcp": false,
|
|
1958
|
+
"cli": {
|
|
1959
|
+
"include": [
|
|
1960
|
+
"list",
|
|
1961
|
+
"get"
|
|
1962
|
+
],
|
|
1963
|
+
"skipApiCheck": true
|
|
1964
|
+
}
|
|
1925
1965
|
},
|
|
1926
1966
|
"extends": "SmrtCollection",
|
|
1927
1967
|
"extendsTypeArg": "SmrtReportLock",
|
|
@@ -2494,7 +2534,14 @@
|
|
|
2494
2534
|
"decoratorConfig": {
|
|
2495
2535
|
"tableName": "_smrt_report_schedules",
|
|
2496
2536
|
"api": false,
|
|
2497
|
-
"mcp": false
|
|
2537
|
+
"mcp": false,
|
|
2538
|
+
"cli": {
|
|
2539
|
+
"include": [
|
|
2540
|
+
"list",
|
|
2541
|
+
"get"
|
|
2542
|
+
],
|
|
2543
|
+
"skipApiCheck": true
|
|
2544
|
+
}
|
|
2498
2545
|
},
|
|
2499
2546
|
"extends": "SmrtCollection",
|
|
2500
2547
|
"extendsTypeArg": "SmrtReportSchedule",
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { ReportAdapterDescriptor, ReportDataQueryResult } from './adapter.js';
|
|
2
|
+
/** Verified request context supplied by an application route, never the browser. */
|
|
3
|
+
export interface MaterializedReportHostContext {
|
|
4
|
+
userId: string;
|
|
5
|
+
tenantId: string;
|
|
6
|
+
permissions: readonly string[];
|
|
7
|
+
}
|
|
8
|
+
/** The only browser-controlled selections accepted by a materialized-report host. */
|
|
9
|
+
export interface MaterializedReportCellRequest {
|
|
10
|
+
id: string;
|
|
11
|
+
columnId: string;
|
|
12
|
+
}
|
|
13
|
+
/** A host-normalized monetary value; values stay in integer minor units. */
|
|
14
|
+
export interface MaterializedReportDrilldownValues {
|
|
15
|
+
valueMinor?: number | null;
|
|
16
|
+
taxableMinor?: number | null;
|
|
17
|
+
taxMinor?: number | null;
|
|
18
|
+
}
|
|
19
|
+
/** One user-visible record contributing to a materialized report cell. */
|
|
20
|
+
export interface MaterializedReportDrilldownContributor {
|
|
21
|
+
id: string;
|
|
22
|
+
source: string;
|
|
23
|
+
identity: string;
|
|
24
|
+
date?: string | null;
|
|
25
|
+
party?: string | null;
|
|
26
|
+
values: MaterializedReportDrilldownValues;
|
|
27
|
+
}
|
|
28
|
+
/** A host-declared warning that keeps incomplete or divergent evidence visible. */
|
|
29
|
+
export interface MaterializedReportDrilldownWarning {
|
|
30
|
+
code: string;
|
|
31
|
+
kind: 'incomplete' | 'drift';
|
|
32
|
+
message: string;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Domain-neutral presentation returned by a materialized-report host.
|
|
36
|
+
*
|
|
37
|
+
* The route serializes this value unchanged. Hosts normalize their own source
|
|
38
|
+
* facts here so generic browser surfaces neither import nor infer report domains.
|
|
39
|
+
*/
|
|
40
|
+
export interface MaterializedReportDrilldownPresentation {
|
|
41
|
+
version: 1;
|
|
42
|
+
selectedMeasure: {
|
|
43
|
+
id: string;
|
|
44
|
+
label: string;
|
|
45
|
+
};
|
|
46
|
+
currency: string;
|
|
47
|
+
contributors: readonly MaterializedReportDrilldownContributor[];
|
|
48
|
+
complete: boolean;
|
|
49
|
+
warnings: readonly MaterializedReportDrilldownWarning[];
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Application-owned bridge for one declared materialized report.
|
|
53
|
+
*
|
|
54
|
+
* Registrations are server-only and bind tenant/principal from the route
|
|
55
|
+
* context. Hosts must re-read the selected row before resolving a cell.
|
|
56
|
+
*/
|
|
57
|
+
export interface MaterializedReportHost<Drilldown = unknown> {
|
|
58
|
+
id: string;
|
|
59
|
+
/** Registered collection and model used by the route's normal read gate. */
|
|
60
|
+
collection: string;
|
|
61
|
+
className: string;
|
|
62
|
+
/** The report host fails closed unless this published permission is present. */
|
|
63
|
+
readPermission: string;
|
|
64
|
+
/** Closed visible columns that may request a source drilldown. */
|
|
65
|
+
drilldownColumnIds: readonly string[];
|
|
66
|
+
descriptor(context: MaterializedReportHostContext): Promise<ReportAdapterDescriptor>;
|
|
67
|
+
query(context: MaterializedReportHostContext, request: unknown): Promise<ReportDataQueryResult>;
|
|
68
|
+
drilldown(context: MaterializedReportHostContext, request: MaterializedReportCellRequest): Promise<Drilldown>;
|
|
69
|
+
}
|
|
70
|
+
export declare function assertMaterializedReportRead(host: MaterializedReportHost, context: MaterializedReportHostContext): void;
|
|
71
|
+
export declare function findMaterializedReportHost(hosts: readonly MaterializedReportHost[], id: string): MaterializedReportHost | undefined;
|
|
72
|
+
//# sourceMappingURL=materialized-host.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"materialized-host.d.ts","sourceRoot":"","sources":["../src/materialized-host.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,uBAAuB,EACvB,qBAAqB,EACtB,MAAM,cAAc,CAAC;AAEtB,oFAAoF;AACpF,MAAM,WAAW,6BAA6B;IAC5C,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,EAAE,SAAS,MAAM,EAAE,CAAC;CAChC;AAED,qFAAqF;AACrF,MAAM,WAAW,6BAA6B;IAC5C,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,4EAA4E;AAC5E,MAAM,WAAW,iCAAiC;IAChD,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAED,0EAA0E;AAC1E,MAAM,WAAW,sCAAsC;IACrD,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,MAAM,EAAE,iCAAiC,CAAC;CAC3C;AAED,mFAAmF;AACnF,MAAM,WAAW,kCAAkC;IACjD,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,YAAY,GAAG,OAAO,CAAC;IAC7B,OAAO,EAAE,MAAM,CAAC;CACjB;AAED;;;;;GAKG;AACH,MAAM,WAAW,uCAAuC;IACtD,OAAO,EAAE,CAAC,CAAC;IACX,eAAe,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;IAC/C,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,SAAS,sCAAsC,EAAE,CAAC;IAChE,QAAQ,EAAE,OAAO,CAAC;IAClB,QAAQ,EAAE,SAAS,kCAAkC,EAAE,CAAC;CACzD;AAED;;;;;GAKG;AACH,MAAM,WAAW,sBAAsB,CAAC,SAAS,GAAG,OAAO;IACzD,EAAE,EAAE,MAAM,CAAC;IACX,4EAA4E;IAC5E,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,gFAAgF;IAChF,cAAc,EAAE,MAAM,CAAC;IACvB,kEAAkE;IAClE,kBAAkB,EAAE,SAAS,MAAM,EAAE,CAAC;IACtC,UAAU,CACR,OAAO,EAAE,6BAA6B,GACrC,OAAO,CAAC,uBAAuB,CAAC,CAAC;IACpC,KAAK,CACH,OAAO,EAAE,6BAA6B,EACtC,OAAO,EAAE,OAAO,GACf,OAAO,CAAC,qBAAqB,CAAC,CAAC;IAClC,SAAS,CACP,OAAO,EAAE,6BAA6B,EACtC,OAAO,EAAE,6BAA6B,GACrC,OAAO,CAAC,SAAS,CAAC,CAAC;CACvB;AAED,wBAAgB,4BAA4B,CAC1C,IAAI,EAAE,sBAAsB,EAC5B,OAAO,EAAE,6BAA6B,GACrC,IAAI,CAQN;AAED,wBAAgB,0BAA0B,CACxC,KAAK,EAAE,SAAS,sBAAsB,EAAE,EACxC,EAAE,EAAE,MAAM,GACT,sBAAsB,GAAG,SAAS,CAEpC"}
|
package/dist/smrt-knowledge.json
CHANGED
|
@@ -3,16 +3,16 @@
|
|
|
3
3
|
"sensitiveFieldsExcluded": true,
|
|
4
4
|
"generatedAt": "1970-01-01T00:00:00.000Z",
|
|
5
5
|
"packageName": "@happyvertical/smrt-reports",
|
|
6
|
-
"packageVersion": "0.56.
|
|
6
|
+
"packageVersion": "0.56.2",
|
|
7
7
|
"sourceManifestPath": "dist/manifest.json",
|
|
8
8
|
"agentDocPath": "AGENTS.md",
|
|
9
9
|
"sourceHashes": {
|
|
10
|
-
"manifest": "
|
|
11
|
-
"packageJson": "
|
|
10
|
+
"manifest": "fdab18674f23cf4b40c743b88f0dba6925b2ac583b8f632e9564090b3b586e98",
|
|
11
|
+
"packageJson": "c826642829d8ff8b9efaaaff9cfc57a2756a4bea292e6acd893a7782176572b5",
|
|
12
12
|
"agents": "c134555effb7869d3fbd3b5fb2be79bca10d0644425aacd9654869a517cc78a3",
|
|
13
13
|
"moduleDoc:agents/runtime-report-browser.md": "7b35c4b84c8a7dfc733198f793c701743ba6d7cb586ec9b671bddd645ecf0e8d",
|
|
14
14
|
"moduleDoc:agents/runtime-reports.md": "04cc898425ae1af4541056e6abd2f48c381ba20b7322b87cc45fd1afb1392313",
|
|
15
|
-
"moduleDoc:README.md": "
|
|
15
|
+
"moduleDoc:README.md": "cf7d385aefa3abd225ed4eb2505925155a71b30accb81f8349d86b56064dae2c"
|
|
16
16
|
},
|
|
17
17
|
"exports": [
|
|
18
18
|
".",
|
|
@@ -1277,7 +1277,7 @@
|
|
|
1277
1277
|
{
|
|
1278
1278
|
"path": "README.md",
|
|
1279
1279
|
"module": "README",
|
|
1280
|
-
"content": "# @happyvertical/smrt-reports\n\nMaterialized aggregate reports for s-m-r-t. Define a report as a decorated class,\ncompile it to a portable aggregate query, and refresh its normal s-m-r-t table\nmanually, on a schedule, after source changes, or when a TTL expires.\n\nRuntime refresh checks that its tables exist (existence only, not columns or\nindexes) and fails clearly if they do not; it does not create report or system\ntables. Include report models in normal manifest-driven migrations before\nrefreshing.\n\n## Installation\n\n```bash\npnpm add @happyvertical/smrt-reports\n```\n\n## Define a report\n\n```ts\nimport { SmrtObject, smrt } from '@happyvertical/smrt-core';\nimport {\n groupBy,\n month,\n report,\n SmrtReport,\n SmrtReportCollection,\n sum,\n} from '@happyvertical/smrt-reports';\n\n@smrt()\nclass Invoice extends SmrtObject {\n customerId = '';\n issuedAt = new Date();\n totalAmount = 0.0;\n status = '';\n}\n\n@report({\n source: Invoice,\n where: { status: 'paid' },\n refresh: { manual: true },\n})\n@smrt()\nclass MonthlyRevenue extends SmrtReport {\n @groupBy('customerId') customerId = '';\n @month('issuedAt') issuedMonth = new Date();\n @sum('totalAmount') revenue = 0.0;\n}\n\nclass MonthlyRevenueCollection extends SmrtReportCollection<MonthlyRevenue> {\n static readonly _itemClass = MonthlyRevenue;\n}\n\nconst reports = await MonthlyRevenueCollection.create({ db: 'app.db' });\nawait reports.refresh();\nconst rows = await reports.list({ orderBy: 'issued_month DESC' });\n```\n\n## Decorators\n\n| Decorator | Meaning |\n| --- | --- |\n| `@report({ source, ... })` | Declare the source and refresh policy |\n| `@groupBy(column?)` | Group by a source column |\n| `@minute()` through `@year()` | Bucket a source timestamp |\n| `@sum()`, `@avg()`, `@min()`, `@max()` | Aggregate a source column |\n| `@count()` | Count rows or distinct values |\n| `@aggregate()` | Define a custom supported aggregate shape |\n\nEvery non-system report field must be a grouping key, time bucket, or aggregate.\nMetadata is stored under the field's `_meta.__report` contract so scanner and\nruntime compilation stay aligned.\n\n## Refresh modes\n\n- `rebuild` replaces the materialized result.\n- `incremental` recomputes groups affected after a source watermark and removes\n groups that become empty.\n- TTL-backed collections refresh before reads when the report is stale.\n- `ReportScheduleRunner` and `registerReportRefreshInterceptor()` enqueue\n durable refresh jobs through [`smrt-jobs`](../jobs/README.md).\n\nIncremental sources need watermark and soft-delete columns, normally\n`updatedAt` and `deletedAt`. Tenant-scoped aggregate SQL explicitly filters\n`tenant_id`; normal collection interceptors cannot secure a raw aggregate.\n\n## Public entry points\n\nThe package exposes focused subpaths for consumers that want only the compiler,\ndecorators, refresh engine, scheduler, state models, or aggregate compatibility\nhelpers:\n\n```text\n@happyvertical/smrt-reports\n@happyvertical/smrt-reports/compiler\n@happyvertical/smrt-reports/decorators\n@happyvertical/smrt-reports/refresh\n@happyvertical/smrt-reports/scheduler\n@happyvertical/smrt-reports/state\n@happyvertical/smrt-reports/aggregate\n```\n\n## Report adapter\n\nThe package also exports a transport-neutral adapter for report surfaces:\n\n```ts\nimport {\n buildReportAdapterDescriptor,\n buildReportDrilldownQuery,\n queryReportMaterializedRows,\n reportMaterializedRowKey,\n splitReportFilterScopes,\n} from '@happyvertical/smrt-reports';\n\nconst descriptor = await buildReportAdapterDescriptor(MonthlyRevenue, {\n tenantScope: 'current',\n});\nconst reports = await MonthlyRevenueCollection.create({ db: 'app.db' });\nconst result = await queryReportMaterializedRows(\n MonthlyRevenue,\n {\n version: 1,\n requestId: 'monthly-revenue-first-page',\n mode: 'rows',\n projection: ['id', 'customer_id', 'revenue'],\n filter: {\n kind: 'all',\n filters: [\n {\n kind: 'condition',\n field: 'customer_id',\n operator: 'eq',\n value: 'customer-42',\n },\n {\n kind: 'condition',\n field: 'revenue',\n operator: 'gte',\n value: 1000,\n },\n ],\n },\n page: { kind: 'offset', offset: 0, limit: 25 },\n sort: [{ field: 'revenue', direction: 'desc' }],\n },\n {\n collection: reports,\n // Preserve the resource identity of the descriptor selected above.\n adapter: { tenantScope: 'current' },\n },\n);\nconst rowKey = reportMaterializedRowKey(result.rows[0]);\nconst drilldown = await buildReportDrilldownQuery(MonthlyRevenue, result.rows[0]);\n```\n\n`ReportAdapterDescriptor` is deterministic JSON with a stable resource id,\n`id` as the identity field, typed report columns, a canonical `DataQuerySchema`,\nand structural DataTable hints. The hints are deliberately not a `smrt-ui`\ndependency. Consumers may map the descriptor to their own presentation layer.\nThe adapter exposes only persisted report columns and the primary key: transient,\nsystem, and non-column fields are not surfaced. Sensitive/secret fields and\nfields with a `readPermission` are excluded without a principal, so the\ndescriptor fails closed.\n\nThe `dataTable.columns` entries carry neutral `headerPath`, `valueFormat`,\nalignment, role, and responsive hints. Grouping fields, time buckets, and\naggregate measures receive deterministic multi-level header ancestry; a\nconsumer can override any column by stable id with\n`buildReportAdapterDescriptor(..., { dataTable: { columns: { ... } } })`.\n`valueFormat` is an instruction for the rendering boundary only: rows returned\nby `queryReportMaterializedRows()` retain their raw JSON-safe values for\nsorting, exports, and agents. Use `dataTable.structuralRows` for a computed\nsummary, subtotal, aggregate, or footer. Each is marked `selection: 'excluded'`\nand `actions: 'excluded'`, and must be passed to the consumer table's structural\nrow surface rather than its selectable data rows.\n\n`queryReportMaterializedRows()` is the bounded read slice for already-materialized\nrows. It supports projection, offset/limit paging (default limit 50),\ndeterministic multi-sort (with `id` as the final tie-break), typed filters, and\ndatabase-backed dimension facets. A descriptor marks group/time fields as\n`filterScope: 'where'` and aggregate measures as `filterScope: 'having'`; use\n`splitReportFilterScopes()` when constructing a live source query. An AND may\ncombine the two scopes, but an OR or NOT cannot mix them, because moving either\nside across a source `WHERE`/`HAVING` boundary would change its meaning.\n\nMaterialized query results normalize hydrated values against their declared\ncolumn types. Objects in scalar columns become JSON text; JSON columns retain\nstructured values. Date-only and timezone-less timestamps use UTC, matching\nSQLite storage, and temporal facet buckets follow the same rule. Strings over\n4096 characters are shortened with `truncated` and a warning, except row IDs:\nidentities are never rewritten, and unsupported IDs fail validation. Unsafe bigint\nvalues fail instead of silently losing precision.\n\nThe materialized collection executes the normalized, allowlisted predicate as\nparameterized SQL and applies it identically to rows, totals, and facets. It\nnever accepts raw SQL, source field paths, tenant ids, or principal ids.\nThe descriptor's `queryExecution` contract declares three delivery choices:\n`visible` (the default) returns rows for an already-authorized surface,\n`silent` returns the same bounded result while making no visible-surface change,\nand `background` delegates a normalized, authority-free task to the application's\n`enqueueBackgroundQuery` host. A background result is only a queue handle, never\nmaterialized rows. The host retains the authenticated principal, tenant, report\ndefinition, field policy, database, and eventual job execution; none can be\nsupplied in a query request or background task.\nWhen a consumer selects a descriptor with `ReportAdapterOptions`, pass those\nsame options through the query's `adapter` option so returned and background\ntask resource ids stay aligned with the selected descriptor.\n`buildReportDrilldownQuery()` carries only the row's declared groups/buckets\nand a fixed inheritance contract for the current principal, tenant, report\ndefinition, and field policy; an authenticated source adapter must enforce that\ncontract before it reads source records. Time buckets remain declarative so the\nsource adapter keeps the report's database/timezone semantics. Every returned\nrow must have a non-empty string `id`; use that value, never a display or page\nindex, for row identity.\n\nWhen no collection is injected, reads resolve the registered report collection\nthrough `ObjectRegistry`, allowing normal s-m-r-t collection interceptors to enforce\ntenant filtering. `tenantScoped` and `tenantField` in the descriptor are true\nonly when the report is actually registered with tenant metadata; a scope string\nalone is not an authority boundary. An injected collection is application-owned\nand must provide the equivalent tenant boundary. Raw aggregate refreshes still\nneed explicit tenant predicates as described above.\n\nThe descriptor's `refresh` section declares mode, triggers, stale-read behavior,\nand a permissioned, audited `refresh` action with `preview` and `apply` phases.\nThe adapter itself stays read-only. Call `getReportLifecycle()` for a tenant-safe\nsnapshot of current, stale, refreshing, lock-skipped, or failed materialization\nstate; it redacts lock owners, raw errors, and tenant-fanout identifiers. Pass a\n`lifecycle` option to `queryReportMaterializedRows()` only when a consumer needs\nthat context; the result then distinguishes a current, stale, or read-triggered\nrefresh. `previewReportRefresh()` and `applyReportRefresh()` require an\napplication action host to authorize and audit the caller before a durable\nreport-refresh job is queued. Manual refresh hosts must also return a versioned,\nnon-secret `executionAuthority` binding. Each worker process registers the named\nhost with `registerReportRefreshExecutionAuthorityHost()`; the task checks the\npersisted tenant binding, resolves current membership and permission, and records\nan allowed or denied execution audit immediately before refresh. Every enqueue\npath also receives a `DurableJobPayloadSigner`, and every worker registers it\nwith `registerReportRefreshJobIntegritySigner()`. This authenticates the task\ntarget, trigger, tenant scope, authority reference, and payload across storage;\nthe signing key is never persisted. Scheduled and on-change maintenance jobs\nremain system-authority jobs and do not carry an initiating user, but their job\npayloads use the same integrity boundary. Only a registered `SmrtReportCollection` can\nsynchronously refresh a stale read, and only when its TTL policy is positive and\nnot manual.\n\n### Scheduled refresh lifecycle\n\nDeclare `refresh: { schedule: '*/5 * * * *', tenantFanout: 'discover' }`\non a tenant-scoped report to discover distinct non-null source tenants each\nscheduled run. Include scopes still present in the materialized table so a\nthe next rebuild removes stale groups for tenants losing their final source row.\nDiscovery preserves the configured refresh mode. Incremental reports still need\na `fullRebuildSchedule` to catch hard deletions that do not advance a source\nwatermark. Provision schedules with `ensureReportRefreshSchedules({ db,\nreports })`; no discovered scopes means no work, and discovery never refreshes\nthe global slice.\nThe worker uses the registered source/report tenant columns and its supplied\nmaintenance executor. Discovery does not grant database privileges or widen a\ntenant-bound or manual job. Keep `tenantFanout: true` with explicit `tenantIds`\nwhen the host owns a fixed tenant list.\n\nInitialize a `ReportScheduleRunner` and a jobs `TaskRunner` against the migrated\ndatabase, then call `const unbind = schedules.bindTaskRunner(tasks)` before\nstarting them. This connects terminal job events to schedule counters, freeing\nthe concurrency slot after success or failure. Repeated binding is idempotent;\ncall `unbind()` on shutdown. Listen for `runner:error` to detect failed counter\nupdates. Persisted failure messages use the jobs package redaction policy.\nDurable payload signing and execution-time authority checks remain\nrequired; completion accounting uses the signed schedule identity.\n\nRepeated schedule provisioning inserts missing identities and preserves existing\nhistory, next-run time, running count, and operator settings. A different cron,\nmode, or tenant scope identifies a different schedule; explicitly disable obsolete\nschedules when changing those settings.\n\n### Saved views and snapshot exports\n\n`normalizeReportSavedView()` and `restoreReportSavedView()` provide the\nserializable view boundary. A storage host owns the saved view's tenant and\nowner; on every restore it must pass the stored payload through the current\ndescriptor. That reapplies field, projection, sorting, grouping, and definition\npolicy, so a stale view cannot reveal a field that is no longer allowed.\n`migrateReportSavedView()` upgrades the original unversioned (or explicit v0)\nlayout to v1 before that current-policy validation; unsupported future versions\nfail clearly rather than being guessed.\n\nBuild an export from a completed materialized-row read with\n`createReportExportSnapshot()`, supplying an opaque binding from the\napplication's immutable materialization-snapshot host, then call\n`createReportExportRequest()`. The snapshot fixes the canonical query\nfingerprint, normalized projection and sort, exact row count, `asOf`,\n`refreshedAt`, stale state, and definition fingerprint. Every request also\ncontains a deterministic offset-page read plan; a renderer begins at offset zero\nand advances by its validated page size until the exact bounded row count is\nreached. Use `createReportExportPageRequest()` for each page; it preserves the\nfrozen query semantics while replacing only visible pagination.\n\nEvery request is bounded by rows, bytes, and deadline; exports over the\nforeground row limit become an authority-free background handoff. Use\n`previewReportExport()` and `applyReportExport()` with the same application\naction host for human and agent callers. The host authorizes and audits the\nfixed `reports.export` action; exports containing personal, sensitive, or secret\ncolumns require explicit confirmation.\n\nPreview, apply, and every worker call `validateReportExportExecution()`, which\nrequires the host to prove that the opaque binding still resolves to the exact\nimmutable materialization snapshot under its current principal, tenant, report\ndefinition, and field policy. If it cannot, the operation fails rather than\nrelabelling newer rows with an old `asOf`. Artifact metadata has no URL or\ndownload token; before serving it, call `validateReportExportArtifact()` to\nreject expiry, definition drift, and out-of-bounds progress, then apply the\nhost's current authorization and snapshot validation again.\n\n## Runtime reports\n\nA report can also be defined at runtime as data. `RuntimeReportSpec` is a closed,\nversioned structure (source id, dimensions, measures, filters, having, sort,\nchart hint) with no free-form query text. `compileRuntimeReportSpec()` checks it\nagainst a server-owned source allow-list, field policy (sensitive and\npermission-gated fields are not nameable), the caller's permissions, and tenant\nisolation, then targets the same aggregate plan as declared reports.\n`RuntimeReport` stores validated specs per tenant, and a stored spec is\nre-compiled for the running user on every run. Result rows are not stored. A row\ncan only be inserted, or have its spec changed, through `saveRuntimeReport()`\nwith the spec hash a human confirmed; the model layer refuses every other write. The\nchat package offers matching assistant tools that propose a spec, preview it,\nand save only after a human confirms. See\n[`agents/runtime-reports.md`](./agents/runtime-reports.md).\n\n## Browser components\n\n`@happyvertical/smrt-reports/svelte` exports saved-report and detail screens,\na confirmation card, a report/measure picker, and a shared-chart result view.\nThey accept a principal-scoped browser transport and show an unavailable state\nwhen no server is supplied. See the [browser integration contract](./agents/runtime-report-browser.md)\nfor route-host identity and capability props, confirmation wiring, recipe\nsurfaces, and playground previews.\n\n## Development\n\n```bash\npnpm --filter @happyvertical/smrt-reports test\npnpm --filter @happyvertical/smrt-reports typecheck\npnpm --filter @happyvertical/smrt-reports build\n```\n\nSee [`AGENTS.md`](./AGENTS.md) for refresh, schema, and tenancy invariants.\n"
|
|
1280
|
+
"content": "# @happyvertical/smrt-reports\n\nMaterialized aggregate reports for s-m-r-t. Define a report as a decorated class,\ncompile it to a portable aggregate query, and refresh its normal s-m-r-t table\nmanually, on a schedule, after source changes, or when a TTL expires.\n\nRuntime refresh checks that its tables exist (existence only, not columns or\nindexes) and fails clearly if they do not; it does not create report or system\ntables. Include report models in normal manifest-driven migrations before\nrefreshing.\n\n## Installation\n\n```bash\npnpm add @happyvertical/smrt-reports\n```\n\n## Define a report\n\n```ts\nimport { SmrtObject, smrt } from '@happyvertical/smrt-core';\nimport {\n groupBy,\n month,\n report,\n SmrtReport,\n SmrtReportCollection,\n sum,\n} from '@happyvertical/smrt-reports';\n\n@smrt()\nclass Invoice extends SmrtObject {\n customerId = '';\n issuedAt = new Date();\n subtotal = 0.0;\n status = '';\n}\n\n@report({\n source: Invoice,\n where: { status: 'paid' },\n refresh: { manual: true },\n})\n@smrt()\nclass MonthlyRevenue extends SmrtReport {\n @groupBy('customerId') customerId = '';\n @month('issuedAt') issuedMonth = new Date();\n @sum('subtotal') revenue = 0.0;\n}\n\nclass MonthlyRevenueCollection extends SmrtReportCollection<MonthlyRevenue> {\n static readonly _itemClass = MonthlyRevenue;\n}\n\nconst reports = await MonthlyRevenueCollection.create({ db: 'app.db' });\nawait reports.refresh();\nconst rows = await reports.list({ orderBy: 'issued_month DESC' });\n```\n\n## Decorators\n\n| Decorator | Meaning |\n| --- | --- |\n| `@report({ source, ... })` | Declare the source and refresh policy |\n| `@groupBy(column?)` | Group by a source column |\n| `@minute()` through `@year()` | Bucket a source timestamp |\n| `@sum()`, `@avg()`, `@min()`, `@max()` | Aggregate a source column |\n| `@count()` | Count rows or distinct values |\n| `@aggregate()` | Define a custom supported aggregate shape |\n\nEvery non-system report field must be a grouping key, time bucket, or aggregate.\nMetadata is stored under the field's `_meta.__report` contract so scanner and\nruntime compilation stay aligned.\n\n## Refresh modes\n\n- `rebuild` replaces the materialized result.\n- `incremental` recomputes groups affected after a source watermark and removes\n groups that become empty.\n- TTL-backed collections refresh before reads when the report is stale.\n- `ReportScheduleRunner` and `registerReportRefreshInterceptor()` enqueue\n durable refresh jobs through [`smrt-jobs`](../jobs/README.md).\n\nIncremental sources need watermark and soft-delete columns, normally\n`updatedAt` and `deletedAt`. Tenant-scoped aggregate SQL explicitly filters\n`tenant_id`; normal collection interceptors cannot secure a raw aggregate.\n\n## Public entry points\n\nThe package exposes focused subpaths for consumers that want only the compiler,\ndecorators, refresh engine, scheduler, state models, or aggregate compatibility\nhelpers:\n\n```text\n@happyvertical/smrt-reports\n@happyvertical/smrt-reports/compiler\n@happyvertical/smrt-reports/decorators\n@happyvertical/smrt-reports/refresh\n@happyvertical/smrt-reports/scheduler\n@happyvertical/smrt-reports/state\n@happyvertical/smrt-reports/aggregate\n```\n\n## Report adapter\n\nThe package also exports a transport-neutral adapter for report surfaces:\n\n```ts\nimport {\n buildReportAdapterDescriptor,\n buildReportDrilldownQuery,\n queryReportMaterializedRows,\n reportMaterializedRowKey,\n splitReportFilterScopes,\n} from '@happyvertical/smrt-reports';\n\nconst descriptor = await buildReportAdapterDescriptor(MonthlyRevenue, {\n tenantScope: 'current',\n});\nconst reports = await MonthlyRevenueCollection.create({ db: 'app.db' });\nconst result = await queryReportMaterializedRows(\n MonthlyRevenue,\n {\n version: 1,\n requestId: 'monthly-revenue-first-page',\n mode: 'rows',\n projection: ['id', 'customer_id', 'revenue'],\n filter: {\n kind: 'all',\n filters: [\n {\n kind: 'condition',\n field: 'customer_id',\n operator: 'eq',\n value: 'customer-42',\n },\n {\n kind: 'condition',\n field: 'revenue',\n operator: 'gte',\n value: 1000,\n },\n ],\n },\n page: { kind: 'offset', offset: 0, limit: 25 },\n sort: [{ field: 'revenue', direction: 'desc' }],\n },\n {\n collection: reports,\n // Preserve the resource identity of the descriptor selected above.\n adapter: { tenantScope: 'current' },\n },\n);\nconst rowKey = reportMaterializedRowKey(result.rows[0]);\nconst drilldown = await buildReportDrilldownQuery(MonthlyRevenue, result.rows[0]);\n```\n\n`ReportAdapterDescriptor` is deterministic JSON with a stable resource id,\n`id` as the identity field, typed report columns, a canonical `DataQuerySchema`,\nand structural DataTable hints. The hints are deliberately not a `smrt-ui`\ndependency. Consumers may map the descriptor to their own presentation layer.\nThe adapter exposes only persisted report columns and the primary key: transient,\nsystem, and non-column fields are not surfaced. Sensitive/secret fields and\nfields with a `readPermission` are excluded without a principal, so the\ndescriptor fails closed.\n\nThe `dataTable.columns` entries carry neutral `headerPath`, `valueFormat`,\nalignment, role, and responsive hints. Grouping fields, time buckets, and\naggregate measures receive deterministic multi-level header ancestry; a\nconsumer can override any column by stable id with\n`buildReportAdapterDescriptor(..., { dataTable: { columns: { ... } } })`.\n`valueFormat` is an instruction for the rendering boundary only: rows returned\nby `queryReportMaterializedRows()` retain their raw JSON-safe values for\nsorting, exports, and agents. Use `dataTable.structuralRows` for a computed\nsummary, subtotal, aggregate, or footer. Each is marked `selection: 'excluded'`\nand `actions: 'excluded'`, and must be passed to the consumer table's structural\nrow surface rather than its selectable data rows.\n\n`queryReportMaterializedRows()` is the bounded read slice for already-materialized\nrows. It supports projection, offset/limit paging (default limit 50),\ndeterministic multi-sort (with `id` as the final tie-break), typed filters, and\ndatabase-backed dimension facets. A descriptor marks group/time fields as\n`filterScope: 'where'` and aggregate measures as `filterScope: 'having'`; use\n`splitReportFilterScopes()` when constructing a live source query. An AND may\ncombine the two scopes, but an OR or NOT cannot mix them, because moving either\nside across a source `WHERE`/`HAVING` boundary would change its meaning.\n\nMaterialized query results normalize hydrated values against their declared\ncolumn types. Objects in scalar columns become JSON text; JSON columns retain\nstructured values. Date-only and timezone-less timestamps use UTC, matching\nSQLite storage, and temporal facet buckets follow the same rule. Strings over\n4096 characters are shortened with `truncated` and a warning, except row IDs:\nidentities are never rewritten, and unsupported IDs fail validation. Unsafe bigint\nvalues fail instead of silently losing precision.\n\nThe materialized collection executes the normalized, allowlisted predicate as\nparameterized SQL and applies it identically to rows, totals, and facets. It\nnever accepts raw SQL, source field paths, tenant ids, or principal ids.\nThe descriptor's `queryExecution` contract declares three delivery choices:\n`visible` (the default) returns rows for an already-authorized surface,\n`silent` returns the same bounded result while making no visible-surface change,\nand `background` delegates a normalized, authority-free task to the application's\n`enqueueBackgroundQuery` host. A background result is only a queue handle, never\nmaterialized rows. The host retains the authenticated principal, tenant, report\ndefinition, field policy, database, and eventual job execution; none can be\nsupplied in a query request or background task.\nWhen a consumer selects a descriptor with `ReportAdapterOptions`, pass those\nsame options through the query's `adapter` option so returned and background\ntask resource ids stay aligned with the selected descriptor.\n`buildReportDrilldownQuery()` carries only the row's declared groups/buckets\nand a fixed inheritance contract for the current principal, tenant, report\ndefinition, and field policy; an authenticated source adapter must enforce that\ncontract before it reads source records. Time buckets remain declarative so the\nsource adapter keeps the report's database/timezone semantics. Every returned\nrow must have a non-empty string `id`; use that value, never a display or page\nindex, for row identity.\n\nWhen no collection is injected, reads resolve the registered report collection\nthrough `ObjectRegistry`, allowing normal s-m-r-t collection interceptors to enforce\ntenant filtering. `tenantScoped` and `tenantField` in the descriptor are true\nonly when the report is actually registered with tenant metadata; a scope string\nalone is not an authority boundary. An injected collection is application-owned\nand must provide the equivalent tenant boundary. Raw aggregate refreshes still\nneed explicit tenant predicates as described above.\n\nThe descriptor's `refresh` section declares mode, triggers, stale-read behavior,\nand a permissioned, audited `refresh` action with `preview` and `apply` phases.\nThe adapter itself stays read-only. Call `getReportLifecycle()` for a tenant-safe\nsnapshot of current, stale, refreshing, lock-skipped, or failed materialization\nstate; it redacts lock owners, raw errors, and tenant-fanout identifiers. Pass a\n`lifecycle` option to `queryReportMaterializedRows()` only when a consumer needs\nthat context; the result then distinguishes a current, stale, or read-triggered\nrefresh. `previewReportRefresh()` and `applyReportRefresh()` require an\napplication action host to authorize and audit the caller before a durable\nreport-refresh job is queued. Manual refresh hosts must also return a versioned,\nnon-secret `executionAuthority` binding. Each worker process registers the named\nhost with `registerReportRefreshExecutionAuthorityHost()`; the task checks the\npersisted tenant binding, resolves current membership and permission, and records\nan allowed or denied execution audit immediately before refresh. Every enqueue\npath also receives a `DurableJobPayloadSigner`, and every worker registers it\nwith `registerReportRefreshJobIntegritySigner()`. This authenticates the task\ntarget, trigger, tenant scope, authority reference, and payload across storage;\nthe signing key is never persisted. Scheduled and on-change maintenance jobs\nremain system-authority jobs and do not carry an initiating user, but their job\npayloads use the same integrity boundary. Only a registered `SmrtReportCollection` can\nsynchronously refresh a stale read, and only when its TTL policy is positive and\nnot manual.\n\n### Scheduled refresh lifecycle\n\nDeclare `refresh: { schedule: '*/5 * * * *', tenantFanout: 'discover' }`\non a tenant-scoped report to discover distinct non-null source tenants each\nscheduled run. Include scopes still present in the materialized table so a\nthe next rebuild removes stale groups for tenants losing their final source row.\nDiscovery preserves the configured refresh mode. Incremental reports still need\na `fullRebuildSchedule` to catch hard deletions that do not advance a source\nwatermark. Provision schedules with `ensureReportRefreshSchedules({ db,\nreports })`; no discovered scopes means no work, and discovery never refreshes\nthe global slice.\nThe worker uses the registered source/report tenant columns and its supplied\nmaintenance executor. Discovery does not grant database privileges or widen a\ntenant-bound or manual job. Keep `tenantFanout: true` with explicit `tenantIds`\nwhen the host owns a fixed tenant list.\n\nInitialize a `ReportScheduleRunner` and a jobs `TaskRunner` against the migrated\ndatabase, then call `const unbind = schedules.bindTaskRunner(tasks)` before\nstarting them. This connects terminal job events to schedule counters, freeing\nthe concurrency slot after success or failure. Repeated binding is idempotent;\ncall `unbind()` on shutdown. Listen for `runner:error` to detect failed counter\nupdates. Persisted failure messages use the jobs package redaction policy.\nDurable payload signing and execution-time authority checks remain\nrequired; completion accounting uses the signed schedule identity.\n\nRepeated schedule provisioning inserts missing identities and preserves existing\nhistory, next-run time, running count, and operator settings. A different cron,\nmode, or tenant scope identifies a different schedule; explicitly disable obsolete\nschedules when changing those settings.\n\n### Saved views and snapshot exports\n\n`normalizeReportSavedView()` and `restoreReportSavedView()` provide the\nserializable view boundary. A storage host owns the saved view's tenant and\nowner; on every restore it must pass the stored payload through the current\ndescriptor. That reapplies field, projection, sorting, grouping, and definition\npolicy, so a stale view cannot reveal a field that is no longer allowed.\n`migrateReportSavedView()` upgrades the original unversioned (or explicit v0)\nlayout to v1 before that current-policy validation; unsupported future versions\nfail clearly rather than being guessed.\n\nBuild an export from a completed materialized-row read with\n`createReportExportSnapshot()`, supplying an opaque binding from the\napplication's immutable materialization-snapshot host, then call\n`createReportExportRequest()`. The snapshot fixes the canonical query\nfingerprint, normalized projection and sort, exact row count, `asOf`,\n`refreshedAt`, stale state, and definition fingerprint. Every request also\ncontains a deterministic offset-page read plan; a renderer begins at offset zero\nand advances by its validated page size until the exact bounded row count is\nreached. Use `createReportExportPageRequest()` for each page; it preserves the\nfrozen query semantics while replacing only visible pagination.\n\nEvery request is bounded by rows, bytes, and deadline; exports over the\nforeground row limit become an authority-free background handoff. Use\n`previewReportExport()` and `applyReportExport()` with the same application\naction host for human and agent callers. The host authorizes and audits the\nfixed `reports.export` action; exports containing personal, sensitive, or secret\ncolumns require explicit confirmation.\n\nPreview, apply, and every worker call `validateReportExportExecution()`, which\nrequires the host to prove that the opaque binding still resolves to the exact\nimmutable materialization snapshot under its current principal, tenant, report\ndefinition, and field policy. If it cannot, the operation fails rather than\nrelabelling newer rows with an old `asOf`. Artifact metadata has no URL or\ndownload token; before serving it, call `validateReportExportArtifact()` to\nreject expiry, definition drift, and out-of-bounds progress, then apply the\nhost's current authorization and snapshot validation again.\n\n## Runtime reports\n\nA report can also be defined at runtime as data. `RuntimeReportSpec` is a closed,\nversioned structure (source id, dimensions, measures, filters, having, sort,\nchart hint) with no free-form query text. `compileRuntimeReportSpec()` checks it\nagainst a server-owned source allow-list, field policy (sensitive and\npermission-gated fields are not nameable), the caller's permissions, and tenant\nisolation, then targets the same aggregate plan as declared reports.\n`RuntimeReport` stores validated specs per tenant, and a stored spec is\nre-compiled for the running user on every run. Result rows are not stored. A row\ncan only be inserted, or have its spec changed, through `saveRuntimeReport()`\nwith the spec hash a human confirmed; the model layer refuses every other write. The\nchat package offers matching assistant tools that propose a spec, preview it,\nand save only after a human confirms. See\n[`agents/runtime-reports.md`](./agents/runtime-reports.md).\n\n## Browser components\n\n`@happyvertical/smrt-reports/svelte` exports saved-report and detail screens,\na confirmation card, a report/measure picker, and a shared-chart result view.\nThey accept a principal-scoped browser transport and show an unavailable state\nwhen no server is supplied. See the [browser integration contract](./agents/runtime-report-browser.md)\nfor route-host identity and capability props, confirmation wiring, recipe\nsurfaces, and playground previews.\n\n## Development\n\n```bash\npnpm --filter @happyvertical/smrt-reports test\npnpm --filter @happyvertical/smrt-reports typecheck\npnpm --filter @happyvertical/smrt-reports build\n```\n\nSee [`AGENTS.md`](./AGENTS.md) for refresh, schema, and tenancy invariants.\n"
|
|
1281
1281
|
}
|
|
1282
1282
|
],
|
|
1283
1283
|
"recipes": [
|