The Stacks package

On this page 10

@reportshq/stacks puts the reports inside your application. It reads the models you already have, queries them in place on the connection your application already holds, and renders through the routes you already guard.

Nothing leaves the application. There is no endpoint to send to, no connection to hand out, and the licence check is offline.

The Stacks and Laravel source share a result contract and compiled chart components. Their query implementations are separate and their filter operator vocabularies currently differ. Verify semantics in each runtime instead of assuming they cannot drift.

Install

Do not run that install command yet for the reporting product. npm currently serves 0.1.0, the earlier event-forwarding SDK. The reporting source in this checkout is versioned 0.2.0 but has not been published. Verify that npm serves a release containing the reporting source before installing it. See the quickstart release status for the current boundary.

After a reporting release exists, install it with:

bun add @reportshq/stacks

There is no migration, and that is deliberate. This package ships no tables of its own and never writes to yours: it reads the models you describe below, and it asks the application where reports themselves are kept through a ReportStore you supply. Config, your own tables, a CMS, anything that can answer four methods. See "Where reports live" for both shapes.

That is the one real difference from the Laravel package, which does ship migrations and an Eloquent-backed store. In Laravel the framework convention is that a package brings its own tables; in Stacks migrations are derived from the models an application declares, and there is no mechanism for a package to contribute them. Rather than pretend otherwise, this package hands the decision to you.

Describing a model

The description is the allowlist. A block can only reach what it names, so a column nobody meant to expose is not one click away.

// config/reportshq.ts
import type { ModelDescription } from '@reportshq/stacks'

export const models: Record<string, ModelDescription> = {
  order: {
    // The table, resolved from your model rather than guessed.
    table: 'orders',
    // What may be added up. Nothing outside this is reachable.
    measures: {
      revenue: { aggregate: 'sum', column: 'total_amount', unit: 'currency' },
      orders: { aggregate: 'count' },
    },
    // Which columns are dates worth bucketing by.
    time: {
      placed: 'created_at',
    },
    // What may be grouped by.
    dimensions: {
      status: 'status',
    },
  },
}

Everything except models has a defensible default, because a reporting package should not need a page of configuration before it shows a number. The rest of ReportsHQConfig is optional:

{
  license: null,              // checked offline, never sent anywhere
  timezone: 'UTC',            // buckets are computed in this zone
  routes: {
    enabled: true,
    prefix: '/reports',
    middleware: ['auth'],
    shareMiddleware: [],      // see below - deliberately not inherited
  },
  api: {
    enabled: false,           // a JSON surface is a decision, not a default
    prefix: '/api/reportshq',
    middleware: ['auth'],
  },
}

The config type includes shareMiddleware, but the current Stacks reportRoutes list has no share route. This setting does not publish a link by itself. The Laravel source has the share route and its own middleware decision.

Wiring it up

Three objects and a store. The compiler takes the connection the application already configured rather than opening one, because two pools with two opinions about the same database is not something a reporting package should introduce.

// app/Support/reports.ts
import type { ReportStore } from '@reportshq/stacks'
import { Compiler, createHandlers, Registry, Runner } from '@reportshq/stacks'
import { db } from '@stacksjs/database'
import { models } from '../../config/reportshq'

const registry = new Registry(models)
const runner = new Runner(new Compiler(registry, db, 'postgres'))

const store: ReportStore = {
  async list() { /* ... */ },
  async find(slug) { /* ... */ },
  async blocks(reportId) { /* ... */ },
  async saveLayout(reportId, blocks) { /* ... */ },
}

export const reportHandlers = createHandlers(store, runner, registry)

Where reports live

The store is an interface, not a set of tables, so the application decides. There are two shapes and both are first class.

Reports in code. Back the store with your own config and let saveLayout throw. A report is then reviewed in a pull request and deployed with everything else, there is no editing surface to secure, and there is nothing to migrate. reportshq.org itself runs this way, in config/reportshq.ts. The builder is simply not mounted.

Reports in tables. Give the store real reads and writes and implement the optional half of the interface as well, and the builder becomes usable: people compose reports in the browser and you keep them wherever you like. You own that schema, because you own the migration - the interface only says what the package will ask for, not how you store it.

// The optional half. Absent means read-only, which is a valid answer.
addBlock?:    (reportId, kind)            => Promise<StoredBlock>
saveBlock?:   (reportId, blockId, patch)  => Promise<void>
removeBlock?: (reportId, blockId)         => Promise<void>
publish?:     (reportId)                  => Promise<void>

Measures the compiler will refuse

A measure belongs to the table it is declared on. Summing an order total across joined line items counts the order once per line, so:

// Refused, and says so on the block.
{ model: 'order', measure: 'revenue', dimension: { model: 'product', key: 'name' } }

// Correct: the measure belongs to the line, so the join does not multiply it.
{ model: 'order_item', measure: 'line_revenue', dimension: { model: 'product', key: 'name' } }

What makes that visible is fansOut on the relation. It is the single most important flag in the description: a measure summed across a one-to-many join counts its row once per match, and the compiler refuses rather than answering, because the wrong number is plausible.

relations: {
  product: { table: 'products', through: { table: 'order_items', foreignKey: 'order_id' }, foreignKey: 'product_id', fansOut: true },
}

The refusal reaches the tile with the reason on it. A plausible wrong number is worse than an empty block, because nobody checks a number that looks right.

It reads what you declared, not the whole table

Be aware of what this does not do. The compiler builds SQL and the runner executes it on the application's existing connection, not through the ORM, so model scopes and soft deletes do not apply on their own. If rows are excluded by a scope in your application, declare the same condition on the model here, or the report will count them. A soft-deleted order is still a row.

Timezone

Buckets are computed in the report's timezone, which defaults to UTC. A daily chart in the wrong zone is wrong by one bucket at both ends, and nobody notices until a total is quoted next to a different total.

Where it renders

In a Stacks application a view is the route, so the pages are stx files under resources/views/reports/: dropping [slug].stx in there is what publishes /reports/{slug}. What is left is the schema, the download and the draft, and reportRoutes describes those rather than registering them, so your route file decides the prefix and the middleware:

// routes/reports.ts
import { route } from '@stacksjs/router'
import { isNotFound, reportRoutes } from '@reportshq/stacks'
import { reportHandlers } from '../app/Support/reports'

for (const description of reportRoutes(reportHandlers)) {
  // The two page routes are served by stx. Mounting them again would give one
  // URL two handlers and make which one answers a question of registration order.
  if (description.name = 'reportshq.index' || description.name = 'reportshq.show')
    continue

  route[description.method](description.path, async request => description.handle({
    params: request?.params ?? {},
    body: request?.body ?? {},
  }))
}

A package that calls the router has made those decisions for the application, which is why it describes instead. Note that a route file is only loaded if app/Routes.ts names it.

Or the JSON API, for a front end of your own.

Exports, sharing, schedules

The current Stacks package generates CSV on demand. Its download handler rejects XLSX. The Laravel source implements both formats. Neither format is stored as a report file by the Stacks handler.

Sharing and scheduling in the current source are Laravel features, documented in sharing and schedules and exports.

Requirements

Bun 1.3+ and a Stacks application. The package imports every @stacksjs/* specifier by name and never reaches for a path inside the framework, which is what lets one build serve both a vendored checkout, where those are workspaces under storage/framework/core, and an unvendored one, where they come from npm.

Released under the MIT License.