measurr 0.1.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/LICENSE +21 -0
- package/README.md +340 -0
- package/dist/analytics.d.ts +133 -0
- package/dist/compile.d.ts +84 -0
- package/dist/dataset.d.ts +303 -0
- package/dist/dialects/postgres.d.ts +16 -0
- package/dist/dialects/postgres.js +51 -0
- package/dist/expr.d.ts +210 -0
- package/dist/guidance.d.ts +5 -0
- package/dist/index-e6k30sfp.js +218 -0
- package/dist/index-m0czpsyv.js +1292 -0
- package/dist/index-nrjdssft.js +200 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +78 -0
- package/dist/period.d.ts +44 -0
- package/dist/plan.d.ts +86 -0
- package/dist/query.d.ts +619 -0
- package/dist/testing.d.ts +38 -0
- package/dist/testing.js +191 -0
- package/package.json +84 -0
- package/src/analytics.ts +663 -0
- package/src/compile.ts +332 -0
- package/src/dataset.ts +783 -0
- package/src/dialects/postgres.ts +74 -0
- package/src/expr.ts +539 -0
- package/src/guidance.ts +10 -0
- package/src/index.ts +74 -0
- package/src/period.ts +212 -0
- package/src/plan.ts +588 -0
- package/src/query.ts +399 -0
- package/src/testing.ts +283 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kristoffer Hansen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
# measurr
|
|
2
|
+
|
|
3
|
+
A small, typed semantic layer. You declare **datasets** (one fact table each) with
|
|
4
|
+
**measures** and **dimensions**; callers (people, charts, an LLM tool) send a small JSON
|
|
5
|
+
query; the engine compiles it to SQL for your database, runs it through your driver and
|
|
6
|
+
returns shaped, typed rows.
|
|
7
|
+
|
|
8
|
+
- No SQL text in definitions: datasets are built from typed expression builders, so a
|
|
9
|
+
renamed column, `avg` over a string or `between` on an enum fails typecheck.
|
|
10
|
+
- Database-neutral definitions: dialects plug in through the `Dialect` interface, and Postgres
|
|
11
|
+
ships today (`measurr/postgres`).
|
|
12
|
+
- Tenant-scoped by construction: the engine adds the tenant filter to every statement and
|
|
13
|
+
refuses to run without a tenant.
|
|
14
|
+
- The query schema is generated with Zod, so it doubles as an LLM tool schema
|
|
15
|
+
(`z.toJSONSchema`) and as the request validator. It can be built per caller, offering only
|
|
16
|
+
the datasets they may query.
|
|
17
|
+
- No driver or ORM dependency. `zod` is a peer dependency.
|
|
18
|
+
|
|
19
|
+
## Quick start
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import {
|
|
23
|
+
caseWhen, createAnalytics, defineDataset, dimension, eq, exists, gte, measure, table
|
|
24
|
+
} from 'measurr'
|
|
25
|
+
import { postgresDialect } from 'measurr/postgres'
|
|
26
|
+
|
|
27
|
+
// Row types are plain TypeScript types (Prisma models work as they are).
|
|
28
|
+
const order = table<Order>('orders')
|
|
29
|
+
|
|
30
|
+
// A join declares its ON clause once. Any expression that reads `region` brings the join
|
|
31
|
+
// with it; the planner adds it only when a query uses such an expression.
|
|
32
|
+
const region = order.leftJoin(table<Region>('regions'), (r) =>
|
|
33
|
+
eq(r.col('id'), order.col('regionId'))
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
// A many-to-many link is a standalone table plus its link condition.
|
|
37
|
+
const orderTag = table<OrderTag>('order_tags')
|
|
38
|
+
const tag = orderTag.leftJoin(table<Tag>('tags'), (t) => eq(t.col('id'), orderTag.col('tagId')))
|
|
39
|
+
const refund = table<Refund>('refunds')
|
|
40
|
+
|
|
41
|
+
export const orders = defineDataset({
|
|
42
|
+
key: 'orders',
|
|
43
|
+
label: 'Orders',
|
|
44
|
+
description: 'One row per order. Test orders are excluded.',
|
|
45
|
+
from: order,
|
|
46
|
+
tenantColumn: order.col('tenantId'),
|
|
47
|
+
scope: eq(order.col('isTest'), false),
|
|
48
|
+
time: { created: { column: order.col('createdAt'), label: 'Created' } },
|
|
49
|
+
measures: {
|
|
50
|
+
orders: measure.count({ label: 'Orders' }),
|
|
51
|
+
revenue: measure.sum(order.col('amount'), { label: 'Revenue' }),
|
|
52
|
+
refunded: measure.countWhere(exists(refund, eq(refund.col('orderId'), order.col('id'))), {
|
|
53
|
+
label: 'Refunded orders'
|
|
54
|
+
}),
|
|
55
|
+
paidShare: measure.share({ label: 'Paid', numerator: eq(order.col('status'), 'PAID') }),
|
|
56
|
+
refundRate: measure.ratio('refunded', 'orders', { label: 'Refund rate' })
|
|
57
|
+
},
|
|
58
|
+
dimensions: {
|
|
59
|
+
status: dimension.enum(order.col('status'), OrderStatus, {
|
|
60
|
+
label: 'Status',
|
|
61
|
+
labels: { OPEN: 'Open', PAID: 'Paid', REFUNDED: 'Refunded' }
|
|
62
|
+
}),
|
|
63
|
+
region: dimension.relation({
|
|
64
|
+
label: 'Region', key: region.col('id'), name: region.col('name'), empty: 'No region'
|
|
65
|
+
}),
|
|
66
|
+
tag: dimension.manyToMany({
|
|
67
|
+
label: 'Tag',
|
|
68
|
+
through: {
|
|
69
|
+
table: orderTag,
|
|
70
|
+
on: eq(orderTag.col('orderId'), order.col('id')),
|
|
71
|
+
key: tag.col('id'),
|
|
72
|
+
name: tag.col('name')
|
|
73
|
+
},
|
|
74
|
+
note: 'An order with several tags counts once per tag.'
|
|
75
|
+
}),
|
|
76
|
+
size: dimension.computed({
|
|
77
|
+
label: 'Size',
|
|
78
|
+
expr: caseWhen([[gte(order.col('amount'), 1000), 'large']], 'small'),
|
|
79
|
+
values: { large: 'Large', small: 'Small' }
|
|
80
|
+
}),
|
|
81
|
+
week: dimension.timeBucket('created', 'week'),
|
|
82
|
+
hour: dimension.timePart('created', 'hour')
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
export const analytics = createAnalytics({
|
|
87
|
+
datasets: [orders],
|
|
88
|
+
sources: {
|
|
89
|
+
main: {
|
|
90
|
+
dialect: postgresDialect({ timestamps: 'utc' }),
|
|
91
|
+
// Run read-only, with a statement timeout, through any driver.
|
|
92
|
+
execute: ({ text, values }) => db.unsafe(text, [...values])
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
tenant: (ctx: Ctx) => ctx.organizationId,
|
|
96
|
+
authorize: (dataset, ctx) => ctx.can(`analytics:${dataset.key}`),
|
|
97
|
+
timezone: (ctx) => ctx.timezone,
|
|
98
|
+
onQuery: (event, ctx) => metrics.record(event.dataset, event.durationMs, event.outcome)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
const result = await analytics.query(
|
|
102
|
+
{
|
|
103
|
+
dataset: 'orders',
|
|
104
|
+
measures: ['orders', 'paidShare'],
|
|
105
|
+
groupBy: ['status'],
|
|
106
|
+
filters: [{ dimension: 'tag', op: 'in', values: ['Complaint'] }],
|
|
107
|
+
period: { last: { days: 30 } },
|
|
108
|
+
compareToPrevious: true
|
|
109
|
+
},
|
|
110
|
+
ctx
|
|
111
|
+
)
|
|
112
|
+
result.rows[0]?.status.key // 'OPEN' | 'PAID' | 'REFUNDED'
|
|
113
|
+
result.rows[0]?.paidShare // Ratio | null
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Callers and tools
|
|
117
|
+
|
|
118
|
+
`authorize` is the one access rule. Everything that offers datasets goes through it:
|
|
119
|
+
|
|
120
|
+
- `analytics.listDatasets(ctx)` resolves to the catalog entries (measures, dimensions, closed
|
|
121
|
+
values) of the datasets `ctx` may query.
|
|
122
|
+
- `analytics.querySchema({ ctx })` resolves to the input schema for those datasets, for a tool
|
|
123
|
+
built per caller. It rejects with `AnalyticsError('forbidden')` when nothing is allowed.
|
|
124
|
+
`analytics.querySchema({ datasets })` is the synchronous form for a list you already have
|
|
125
|
+
(typed for those datasets only), and `querySchema()` covers every dataset. Schemas are built once per set of datasets, so
|
|
126
|
+
`querySchema({ ctx })` returns the same object as `querySchema({ datasets })` for the same
|
|
127
|
+
allowed set: cache a tool built from it by its dataset keys.
|
|
128
|
+
- `analytics.query` checks `authorize` again on every call, so a schema offered too widely
|
|
129
|
+
still cannot leak a dataset.
|
|
130
|
+
|
|
131
|
+
`AnalyticsResultSchema` is any dataset's result without its keys: rows of `{ key, label }`
|
|
132
|
+
per grouped dimension and a number or null per measure, with totals, `previous?`, `notes` and
|
|
133
|
+
`truncated`. `analytics.resultSchema(key)` is the same envelope with the dataset's fields, so a
|
|
134
|
+
result that passes one passes the other. Use it for a tool's output contract.
|
|
135
|
+
|
|
136
|
+
`analyticsToolGuidance` is a few markdown bullets for a model's instructions: use the tool for
|
|
137
|
+
every how-many, how-much, per-period or split-by question and never count from lists; state the
|
|
138
|
+
period and filters; compare with the previous period when the change is the point; say when
|
|
139
|
+
grouped rows overlap; ask only when the period is genuinely ambiguous. It does not name the
|
|
140
|
+
tool, so add a line that does:
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
const instructions = `# Numbers
|
|
144
|
+
The analytics tool is analytics_query.
|
|
145
|
+
${analyticsToolGuidance}`
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
`onQuery(event, ctx)` hears every `query` once it succeeds or fails:
|
|
149
|
+
`{ dataset, durationMs, rows, outcome: 'success' | 'error', error? }`. `dataset` is null when
|
|
150
|
+
the input named no known dataset, so free text from a model never becomes a metric label.
|
|
151
|
+
What the hook throws or rejects with is swallowed; it cannot fail a query.
|
|
152
|
+
|
|
153
|
+
## The model
|
|
154
|
+
|
|
155
|
+
| Concept | What it is |
|
|
156
|
+
| --- | --- |
|
|
157
|
+
| Dataset | One fact table: tenant column, optional scope, time fields, measures, dimensions, source. |
|
|
158
|
+
| Measure | `count`, `countWhere`, `countDistinct`, `sum`, `avg`, `median`, `share` (one condition over another) or `ratio` (one measure over another, after aggregation). |
|
|
159
|
+
| Dimension | `enum`, `computed` (closed value sets), `relation` (to-one), `manyToMany`, `number`, `timeBucket` (day, ISO week, month), `timePart` (ISO weekday, hour). |
|
|
160
|
+
| Query | `dataset`, 1-4 `measures`, 0-2 `groupBy`, `filters`, `period`, `time`, `compareToPrevious`, `sort` (by an asked measure or grouped dimension), `limit` (1-100, default 20). |
|
|
161
|
+
| Result | `{ period, rows, totals, previous?, notes, truncated }`. |
|
|
162
|
+
|
|
163
|
+
**Operators per dimension kind.** Closed and open dimensions take `in`, `notIn`, `isEmpty`
|
|
164
|
+
and `isNotEmpty`; numeric and time dimensions take `between`, `isEmpty` and `isNotEmpty`. The
|
|
165
|
+
query types and the generated schema both enforce this. `notIn` keeps rows without a value,
|
|
166
|
+
unlike SQL's `NOT IN`.
|
|
167
|
+
|
|
168
|
+
**Open values are keys or labels.** Filters on `relation` and `manyToMany` dimensions accept
|
|
169
|
+
keys or names (case-insensitive); the engine resolves them before planning. An unknown value
|
|
170
|
+
fails with `AnalyticsError('unknown_value')` listing the valid values, so a model can retry
|
|
171
|
+
once.
|
|
172
|
+
|
|
173
|
+
**Errors carry typed details.** `isAnalyticsError(error, code?)` narrows by code:
|
|
174
|
+
`unknown_value` details are `{ dimension, unknown, valid }` and `invalid_query` details are the
|
|
175
|
+
schema's issues; the other codes have none.
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
try {
|
|
179
|
+
return await analytics.query(input, ctx)
|
|
180
|
+
} catch (error) {
|
|
181
|
+
if (isAnalyticsError(error, 'unknown_value')) return { retryWith: error.details.valid }
|
|
182
|
+
throw error
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
**Periods** are whole local days in the tenant's timezone (`timezone` hook, UTC by default):
|
|
187
|
+
`{ last: { days } }`, `{ from, to }` (inclusive ISO dates) or `{ preset }` (`today`,
|
|
188
|
+
`yesterday`, `thisWeek`, `lastWeek`, `thisMonth`, `lastMonth`, `thisYear`). At most two years.
|
|
189
|
+
The previous period has the same length and ends the day before. Time buckets use the same
|
|
190
|
+
timezone. `time` picks the time field the period applies to; a time dimension names its own.
|
|
191
|
+
|
|
192
|
+
**Totals are a separate, ungrouped statement**, so a many-to-many grouping (an order with
|
|
193
|
+
two tags appears in two rows) never inflates them. Filters on many-to-many dimensions compile
|
|
194
|
+
to `EXISTS`, so they never multiply rows either.
|
|
195
|
+
|
|
196
|
+
**Result types are honest.** `count` measures are `number`; `sum`, `avg` and `median` are
|
|
197
|
+
`number | null`; `share` and `ratio` are a branded `Ratio | null` (0-1). A row has a field only
|
|
198
|
+
for the dimensions it was grouped by. Relation, many-to-many, number and time keys may be
|
|
199
|
+
null (no related row, no value). Enum and computed keys are null only when their expression
|
|
200
|
+
can be: a nullable enum column, or a `caseWhen` without `otherwise`. Name that group with
|
|
201
|
+
`empty` (default "None"), or `coalesce` the column to a value.
|
|
202
|
+
|
|
203
|
+
## Types that do the work
|
|
204
|
+
|
|
205
|
+
- `table<Row>(name).col(key)` only accepts keys of `Row` whose type maps to SQL
|
|
206
|
+
(`number`/`bigint`/Decimal → number, `string` → string, `boolean`, `Date` → timestamp). JSON
|
|
207
|
+
and relation fields are excluded.
|
|
208
|
+
- Operands must match: `eq(numberColumn, 'x')` fails. A column keeps its value type, so
|
|
209
|
+
`eq(order.col('status'), 'PAD')` and `inList(order.col('status'), ['CLOSED'])` fail on an
|
|
210
|
+
enum column (`toText` compares with anything). `.add/.sub/.mul/.div` exist only on number
|
|
211
|
+
expressions; `div` is always floating-point and null when dividing by zero.
|
|
212
|
+
- `measure.avg/sum/median` take numbers, `measure.share` takes booleans, time fields take
|
|
213
|
+
timestamps.
|
|
214
|
+
- Expressions know whether they can be null. A nullable or optional field gives a nullable
|
|
215
|
+
column; `caseWhen` without `otherwise` is nullable; `coalesce` with a plain fallback is not.
|
|
216
|
+
Plain operands are never null, so `eq(column, null)` fails: use `isNull`.
|
|
217
|
+
- `dimension.enum` needs a label for every enum value, and fails when the column can hold a
|
|
218
|
+
value the enum does not list.
|
|
219
|
+
- `dimension.computed` fails when `values` misses a value its `caseWhen` can produce.
|
|
220
|
+
- `dimension.relation` takes its key type from the key column, so a branded id column types
|
|
221
|
+
the key in filters and rows; `relation<number>` over a string column fails.
|
|
222
|
+
- `groupOnly` dimensions cannot be named in `filters`, and `filterOnly` ones cannot be named
|
|
223
|
+
in `groupBy` or `sort`.
|
|
224
|
+
- `measure.ratio('a', 'b')` must name measures of the same dataset; time dimensions must name
|
|
225
|
+
declared time fields.
|
|
226
|
+
- `AnalyticsQuery<typeof analytics>` is the query union; `analytics.query` returns rows typed
|
|
227
|
+
by the query's `measures` and `groupBy`.
|
|
228
|
+
|
|
229
|
+
What the types cannot see, `defineDataset` checks when the module loads: every column is
|
|
230
|
+
reachable from the dataset's table (joins, `exists` subqueries, the many-to-many link), ratio
|
|
231
|
+
measures have no cycles, and a computed dimension's `values` cover every value its expression
|
|
232
|
+
can produce (repeating the type check for expressions typed only as `string`). `createAnalytics` then checks every dataset's source exists and compiles every
|
|
233
|
+
measure, grouping and filter once, so a definition the dialect cannot render fails at startup.
|
|
234
|
+
|
|
235
|
+
## Sources and dialects
|
|
236
|
+
|
|
237
|
+
A source is `{ dialect, execute }`. `execute` receives `{ text, values }` (positional values
|
|
238
|
+
for the `$n` placeholders) and returns rows as plain objects. Read-only transactions, statement timeouts and row
|
|
239
|
+
caps belong in `execute`. A dataset names its source with `source`; with one source it is the
|
|
240
|
+
default.
|
|
241
|
+
|
|
242
|
+
Rows can come back as the driver returns them. The dialect's `decodeNumber` accepts numbers,
|
|
243
|
+
numeric strings, bigints and decimal objects with a `toNumber()` method (Prisma's `Decimal`,
|
|
244
|
+
decimal.js); anything else is null. Relation keys that are numbers decode the same way.
|
|
245
|
+
|
|
246
|
+
For example, Prisma in a read-only transaction that Postgres stops after five seconds:
|
|
247
|
+
|
|
248
|
+
```ts
|
|
249
|
+
const STATEMENT_TIMEOUT_MS = 5_000
|
|
250
|
+
|
|
251
|
+
const main: AnalyticsSource = {
|
|
252
|
+
dialect: postgresDialect({ timestamps: 'utc' }),
|
|
253
|
+
execute: ({ text, values }) =>
|
|
254
|
+
prisma.$transaction(
|
|
255
|
+
async (tx) => {
|
|
256
|
+
await tx.$executeRawUnsafe('SET TRANSACTION READ ONLY')
|
|
257
|
+
await tx.$executeRawUnsafe(`SET LOCAL statement_timeout = ${STATEMENT_TIMEOUT_MS}`)
|
|
258
|
+
return tx.$queryRawUnsafe<Record<string, unknown>[]>(text, ...values)
|
|
259
|
+
},
|
|
260
|
+
{ timeout: STATEMENT_TIMEOUT_MS + 1_000 }
|
|
261
|
+
)
|
|
262
|
+
}
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
**`measurr/postgres`** `postgresDialect({ timestamps })`: `$n` parameters (untyped,
|
|
266
|
+
so Postgres infers them from the column, which keeps enum columns working), double-quoted
|
|
267
|
+
identifiers, `FILTER (WHERE ...)`, `count(DISTINCT ...)`, `percentile_cont`, correlated
|
|
268
|
+
`EXISTS`, buckets with `AT TIME ZONE`, GROUP BY by position. Set `timestamps: 'utc'` for
|
|
269
|
+
`timestamp without time zone` columns that hold UTC (Prisma's `DateTime`); the default
|
|
270
|
+
`'timestamptz'` is for `timestamp with time zone`.
|
|
271
|
+
|
|
272
|
+
## Testing your datasets
|
|
273
|
+
|
|
274
|
+
`measurr/testing` runs the same checks for every dataset against a seeded database:
|
|
275
|
+
|
|
276
|
+
```ts
|
|
277
|
+
import { assertDatasetContract } from 'measurr/testing'
|
|
278
|
+
|
|
279
|
+
await assertDatasetContract({ dataset: orders, source, tenant: seededTenantId })
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
It checks that every measure runs and returns a number or null, every grouping runs and its
|
|
283
|
+
groups add up to the total (except many-to-many), `isEmpty` plus `isNotEmpty` equals the total,
|
|
284
|
+
open dimensions filter by label to their group's count, closed dimensions filter by value, an
|
|
285
|
+
empty tenant sees nothing (tenant isolation), and day buckets in a far-from-UTC timezone
|
|
286
|
+
(default `Pacific/Auckland`) agree with single-day periods.
|
|
287
|
+
|
|
288
|
+
## Extending
|
|
289
|
+
|
|
290
|
+
- **A measure or dimension** is one entry in a dataset. Dimensions are groupable and
|
|
291
|
+
filterable unless `groupOnly` or `filterOnly` says otherwise. The contract kit covers it
|
|
292
|
+
without a new test.
|
|
293
|
+
- **A dataset** is one `defineDataset` call added to `createAnalytics({ datasets })`.
|
|
294
|
+
- **A computed value** is written with the expression builders (`caseWhen`, `exists`,
|
|
295
|
+
`coalesce`, `toText`, `minutesAgo`, comparisons, `and`/`or`/`not`), never SQL. When a
|
|
296
|
+
definition needs something they cannot say, add a builder and render it in every dialect.
|
|
297
|
+
- **A dialect** implements `Dialect` (`name`, `compile`, `decodeNumber`). Most dialects only
|
|
298
|
+
provide a `SqlRenderer` (quoting, parameters, aggregates, division, time buckets and parts)
|
|
299
|
+
and call `renderStatement`. It is done when the snapshot tests and the
|
|
300
|
+
contract kit pass on it.
|
|
301
|
+
|
|
302
|
+
## Stable API
|
|
303
|
+
|
|
304
|
+
Kept stable for publishing: `createAnalytics` and its options, `analytics.query`,
|
|
305
|
+
`explain`, `querySchema`, `resultSchema`, `listDatasets`; `AnalyticsResultSchema`,
|
|
306
|
+
`analyticsToolGuidance`, `AnalyticsQueryEvent`; `defineDataset`, `measure.*`,
|
|
307
|
+
`dimension.*`, `table` / `Table`, the expression builders; the `Dialect`, `SqlRenderer` and
|
|
308
|
+
`AnalyticsSource` contracts; `postgresDialect`, `checkDatasetContract` /
|
|
309
|
+
`assertDatasetContract`; and the types
|
|
310
|
+
`AnalyticsQuery`, `AnalyticsResult`, `Ratio`, `AnalyticsError` codes and their details
|
|
311
|
+
(`isAnalyticsError`). `ExprNode` and `SelectStatement` are
|
|
312
|
+
exported for dialect authors and may grow new node kinds.
|
|
313
|
+
|
|
314
|
+
## Install
|
|
315
|
+
|
|
316
|
+
```sh
|
|
317
|
+
npm install measurr zod
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
`zod` 4 is a peer dependency. The types need TypeScript 6 or newer.
|
|
321
|
+
|
|
322
|
+
## Development
|
|
323
|
+
|
|
324
|
+
```sh
|
|
325
|
+
bun install
|
|
326
|
+
bun run lint # types and Biome
|
|
327
|
+
bun test
|
|
328
|
+
# With a throwaway Postgres (the integration test creates and drops its own tables):
|
|
329
|
+
ANALYTICS_TEST_DATABASE_URL=postgres://... bun test
|
|
330
|
+
bun run build && bun run verify:package
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
The integration test reads `ANALYTICS_TEST_DATABASE_URL`, not `DATABASE_URL`, because Bun
|
|
334
|
+
loads `.env` files and that name may point at a real database. CI runs it against Postgres 17.
|
|
335
|
+
|
|
336
|
+
Releases are automated with Release Please; see [docs/releasing.md](docs/releasing.md).
|
|
337
|
+
|
|
338
|
+
## License
|
|
339
|
+
|
|
340
|
+
MIT
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { CompiledStatement, Dialect } from './compile.js';
|
|
3
|
+
import type { AnyDataset, AnyDimension } from './dataset.js';
|
|
4
|
+
import { type DatasetQuery, type QueryResult, type ResultOf } from './query.js';
|
|
5
|
+
export type Row = Record<string, unknown>;
|
|
6
|
+
/** A database: a dialect to compile with and a function that runs the compiled SQL. */
|
|
7
|
+
export type AnalyticsSource = {
|
|
8
|
+
readonly dialect: Dialect;
|
|
9
|
+
/** Runs one read-only statement and returns its rows as plain objects. */
|
|
10
|
+
execute(statement: CompiledStatement): Promise<readonly Row[]>;
|
|
11
|
+
};
|
|
12
|
+
export type AnalyticsErrorCode = 'invalid_query' | 'forbidden' | 'missing_tenant' | 'invalid_timezone' | 'invalid_period' | 'unknown_value';
|
|
13
|
+
/** What `details` holds for each error code. */
|
|
14
|
+
export type AnalyticsErrorDetails = {
|
|
15
|
+
/** The schema's issues, for a model to fix its query. */
|
|
16
|
+
invalid_query: readonly z.core.$ZodIssue[];
|
|
17
|
+
forbidden: undefined;
|
|
18
|
+
missing_tenant: undefined;
|
|
19
|
+
invalid_timezone: undefined;
|
|
20
|
+
invalid_period: undefined;
|
|
21
|
+
/** The values that matched nothing, and the values that exist. */
|
|
22
|
+
unknown_value: {
|
|
23
|
+
dimension: string;
|
|
24
|
+
unknown: string[];
|
|
25
|
+
valid: {
|
|
26
|
+
key: string;
|
|
27
|
+
label: string;
|
|
28
|
+
}[];
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
export declare class AnalyticsError<C extends AnalyticsErrorCode = AnalyticsErrorCode> extends Error {
|
|
32
|
+
readonly code: C;
|
|
33
|
+
readonly name = "AnalyticsError";
|
|
34
|
+
readonly details: AnalyticsErrorDetails[C];
|
|
35
|
+
constructor(code: C, message: string, ...[details]: AnalyticsErrorDetails[C] extends undefined ? [] : [details: AnalyticsErrorDetails[C]]);
|
|
36
|
+
}
|
|
37
|
+
/** One `AnalyticsError` type per code, so checking `code` narrows `details`. */
|
|
38
|
+
export type AnalyticsErrorOf<C extends AnalyticsErrorCode = AnalyticsErrorCode> = C extends AnalyticsErrorCode ? AnalyticsError<C> : never;
|
|
39
|
+
/**
|
|
40
|
+
* Whether `error` is an `AnalyticsError` (with `code`, when given). The result narrows by
|
|
41
|
+
* code: `if (isAnalyticsError(error, 'unknown_value')) error.details.valid`.
|
|
42
|
+
*/
|
|
43
|
+
export declare function isAnalyticsError<C extends AnalyticsErrorCode = AnalyticsErrorCode>(error: unknown, code?: C): error is AnalyticsErrorOf<C>;
|
|
44
|
+
/** What `onQuery` hears about each `analytics.query` call. */
|
|
45
|
+
export type AnalyticsQueryEvent = {
|
|
46
|
+
/** The dataset asked for; null when the input named no known dataset. */
|
|
47
|
+
dataset: string | null;
|
|
48
|
+
durationMs: number;
|
|
49
|
+
/** Rows returned; 0 on error. */
|
|
50
|
+
rows: number;
|
|
51
|
+
outcome: 'success' | 'error';
|
|
52
|
+
/** What the query threw, on error. */
|
|
53
|
+
error?: unknown;
|
|
54
|
+
};
|
|
55
|
+
export type AnalyticsOptions<DS extends readonly AnyDataset[], Ctx> = {
|
|
56
|
+
datasets: DS;
|
|
57
|
+
/** Databases by name; a dataset's `source` picks one, or the only one is used. */
|
|
58
|
+
sources: Record<string, AnalyticsSource>;
|
|
59
|
+
/** The tenant every query is scoped to. Queries without one are refused. */
|
|
60
|
+
tenant: (ctx: Ctx) => string | number | null | undefined;
|
|
61
|
+
/**
|
|
62
|
+
* Whether `ctx` may query `dataset`. Everything is allowed when omitted. It decides what
|
|
63
|
+
* `listDatasets(ctx)` and `querySchema({ ctx })` offer, and is checked again on every query.
|
|
64
|
+
*/
|
|
65
|
+
authorize?: ((dataset: DS[number], ctx: Ctx) => boolean | Promise<boolean>) | undefined;
|
|
66
|
+
/** IANA timezone periods and time buckets resolve in. UTC when omitted. */
|
|
67
|
+
timezone?: ((ctx: Ctx) => string | null | undefined | Promise<string | null | undefined>) | undefined;
|
|
68
|
+
/** The clock periods and `minutesAgo` resolve against. */
|
|
69
|
+
now?: (() => Date) | undefined;
|
|
70
|
+
/**
|
|
71
|
+
* Called once per `query`, after it succeeds or fails: for metrics and logs. What it
|
|
72
|
+
* throws or rejects with is swallowed, so it can never fail a query.
|
|
73
|
+
*/
|
|
74
|
+
onQuery?: ((event: AnalyticsQueryEvent, ctx: Ctx) => unknown) | undefined;
|
|
75
|
+
};
|
|
76
|
+
export type DatasetCatalogEntry<Key extends string = string> = {
|
|
77
|
+
key: Key;
|
|
78
|
+
label: string;
|
|
79
|
+
description: string;
|
|
80
|
+
time: {
|
|
81
|
+
key: string;
|
|
82
|
+
label: string;
|
|
83
|
+
}[];
|
|
84
|
+
measures: {
|
|
85
|
+
key: string;
|
|
86
|
+
label: string;
|
|
87
|
+
description: string | undefined;
|
|
88
|
+
}[];
|
|
89
|
+
dimensions: {
|
|
90
|
+
key: string;
|
|
91
|
+
label: string;
|
|
92
|
+
description: string | undefined;
|
|
93
|
+
kind: AnyDimension['kind'];
|
|
94
|
+
category: AnyDimension['category'];
|
|
95
|
+
groupable: boolean;
|
|
96
|
+
filterable: boolean;
|
|
97
|
+
values: {
|
|
98
|
+
key: string;
|
|
99
|
+
label: string;
|
|
100
|
+
}[] | undefined;
|
|
101
|
+
}[];
|
|
102
|
+
};
|
|
103
|
+
export type Analytics<DS extends readonly AnyDataset[], Ctx> = {
|
|
104
|
+
readonly datasets: DS;
|
|
105
|
+
/** Parses, authorizes, plans, compiles, runs and shapes one query. */
|
|
106
|
+
query<const Q extends DatasetQuery<DS[number]>>(input: Q, ctx: Ctx): Promise<ResultOf<DS[number], Q>>;
|
|
107
|
+
/** The compiled statements a query would run, without running them or resolving labels. */
|
|
108
|
+
explain(input: DatasetQuery<DS[number]>, ctx: Ctx): Promise<CompiledStatement[]>;
|
|
109
|
+
/**
|
|
110
|
+
* The input schema: a discriminated union on `dataset`, for the datasets listed (all when
|
|
111
|
+
* omitted), typed for those datasets only. One schema is built per set of datasets and
|
|
112
|
+
* reused.
|
|
113
|
+
*/
|
|
114
|
+
querySchema<const K extends DS[number]['key'] = DS[number]['key']>(options?: {
|
|
115
|
+
datasets?: readonly K[] | undefined;
|
|
116
|
+
}): z.ZodType<DatasetQuery<Extract<DS[number], {
|
|
117
|
+
key: K;
|
|
118
|
+
}>>>;
|
|
119
|
+
/**
|
|
120
|
+
* The input schema for the datasets `authorize` allows `ctx`, for a tool built per caller.
|
|
121
|
+
* Rejects with `forbidden` when none are allowed.
|
|
122
|
+
*/
|
|
123
|
+
querySchema(options: {
|
|
124
|
+
ctx: Ctx;
|
|
125
|
+
}): Promise<z.ZodType<DatasetQuery<DS[number]>>>;
|
|
126
|
+
/** The result schema for one dataset. */
|
|
127
|
+
resultSchema(dataset: DS[number]['key']): z.ZodType<QueryResult<Row, Row>>;
|
|
128
|
+
/** The datasets `authorize` allows `ctx`, with their measures and dimensions. */
|
|
129
|
+
listDatasets(ctx: Ctx): Promise<DatasetCatalogEntry<DS[number]['key']>[]>;
|
|
130
|
+
};
|
|
131
|
+
export type AnalyticsQuery<A> = A extends Analytics<infer DS, never> ? DatasetQuery<DS[number]> : never;
|
|
132
|
+
export type AnalyticsResult<A, Q> = A extends Analytics<infer DS, never> ? ResultOf<DS[number], Q> : never;
|
|
133
|
+
export declare function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(options: AnalyticsOptions<DS, Ctx>): Analytics<DS, Ctx>;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { type AggregateFn, type ExprNode, type ParamValue, type TableRef, type TimeBucketUnit, type TimePartName } from './expr.js';
|
|
2
|
+
/**
|
|
3
|
+
* A planned query, still free of SQL. The planner builds it; a dialect renders it. Joins are
|
|
4
|
+
* not listed: the renderer adds exactly the joins the statement's columns need.
|
|
5
|
+
*/
|
|
6
|
+
export type SelectStatement = {
|
|
7
|
+
readonly from: TableRef;
|
|
8
|
+
/**
|
|
9
|
+
* Joins for standalone tables (a many-to-many link table) that apply only when one of
|
|
10
|
+
* their columns is used.
|
|
11
|
+
*/
|
|
12
|
+
readonly virtualJoins: readonly JoinClause[];
|
|
13
|
+
readonly where: readonly ExprNode[];
|
|
14
|
+
readonly select: readonly SelectItem[];
|
|
15
|
+
/** Indexes into `select`. */
|
|
16
|
+
readonly groupBy: readonly number[];
|
|
17
|
+
readonly orderBy: readonly {
|
|
18
|
+
index: number;
|
|
19
|
+
direction: 'asc' | 'desc';
|
|
20
|
+
}[];
|
|
21
|
+
readonly limit: number | null;
|
|
22
|
+
/** The moment the query runs; `minutesAgo` expressions count back from it. */
|
|
23
|
+
readonly now: Date;
|
|
24
|
+
};
|
|
25
|
+
export type SelectItem = {
|
|
26
|
+
readonly alias: string;
|
|
27
|
+
readonly node: ExprNode;
|
|
28
|
+
};
|
|
29
|
+
export type JoinClause = {
|
|
30
|
+
readonly table: TableRef;
|
|
31
|
+
readonly type: 'left' | 'inner';
|
|
32
|
+
readonly on: ExprNode;
|
|
33
|
+
};
|
|
34
|
+
/** SQL text plus its positional parameters. */
|
|
35
|
+
export type CompiledStatement = {
|
|
36
|
+
readonly text: string;
|
|
37
|
+
readonly values: readonly unknown[];
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Everything a database needs: it renders statements and decodes what comes back. A dialect
|
|
41
|
+
* has no driver dependency; the app's `execute` runs the SQL. Postgres ships in this package;
|
|
42
|
+
* another database plugs in by implementing this, usually through a `SqlRenderer`.
|
|
43
|
+
*/
|
|
44
|
+
export type Dialect = {
|
|
45
|
+
readonly name: string;
|
|
46
|
+
compile(statement: SelectStatement): CompiledStatement;
|
|
47
|
+
/**
|
|
48
|
+
* Numbers may arrive as strings or bigints (numerics, 64-bit integers), or as decimal
|
|
49
|
+
* objects (Prisma's `Decimal`).
|
|
50
|
+
*/
|
|
51
|
+
decodeNumber(value: unknown): number | null;
|
|
52
|
+
};
|
|
53
|
+
/** The places where SQL differs between databases. `renderStatement` handles the rest. */
|
|
54
|
+
export type SqlRenderer = {
|
|
55
|
+
readonly name: string;
|
|
56
|
+
quote(identifier: string): string;
|
|
57
|
+
/** Registers a parameter and returns its placeholder. `index` is 1-based. */
|
|
58
|
+
param(value: ParamValue, index: number): {
|
|
59
|
+
placeholder: string;
|
|
60
|
+
value: unknown;
|
|
61
|
+
};
|
|
62
|
+
aggregate(fn: AggregateFn, arg: string | null, where: string | null): string;
|
|
63
|
+
/** Floating-point division that is null when dividing by zero. */
|
|
64
|
+
divide(left: string, right: string): string;
|
|
65
|
+
toText(expr: string): string;
|
|
66
|
+
/** A `YYYY-MM-DD` string of the bucket's first day in `timezone`. */
|
|
67
|
+
timeBucket(unit: TimeBucketUnit, expr: string, timezone: string): string;
|
|
68
|
+
/** An integer: ISO weekday 1-7 or hour 0-23 in `timezone`. */
|
|
69
|
+
timePart(part: TimePartName, expr: string, timezone: string): string;
|
|
70
|
+
};
|
|
71
|
+
export declare class CompileError extends Error {
|
|
72
|
+
readonly name = "CompileError";
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Decodes numbers the way drivers return them: numbers, numeric strings, bigints and decimal
|
|
76
|
+
* objects with a `toNumber()` method (Prisma's `Decimal`, decimal.js). Anything else is null.
|
|
77
|
+
*/
|
|
78
|
+
export declare function decodeNumber(value: unknown): number | null;
|
|
79
|
+
export declare function renderStatement(statement: SelectStatement, renderer: SqlRenderer): CompiledStatement;
|
|
80
|
+
/**
|
|
81
|
+
* The joins `nodes` need, parents before children and `ON` dependencies before the join that
|
|
82
|
+
* uses them. Columns of other roots (such as a subquery's own table) are left alone.
|
|
83
|
+
*/
|
|
84
|
+
export declare function collectJoins(nodes: readonly ExprNode[], root: TableRef, virtualJoins: readonly JoinClause[]): JoinClause[];
|