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.
@@ -0,0 +1,191 @@
1
+ import {
2
+ MAX_MEASURES,
3
+ createAnalytics2,
4
+ measure2,
5
+ dimension2
6
+ } from "./index-m0czpsyv.js";
7
+
8
+ // src/testing.ts
9
+ var COUNT = "__contractCount";
10
+ var dayKey = (time) => `__contractDay_${time}`;
11
+ async function checkDatasetContract(options) {
12
+ const { dataset } = options;
13
+ const timezone = options.timezone ?? "Pacific/Auckland";
14
+ const period = options.period ?? { last: { days: 365 } };
15
+ const emptyTenant = options.emptyTenant ?? (typeof options.tenant === "number" ? -1 : "__contract_empty_tenant__");
16
+ const probe = {
17
+ ...dataset,
18
+ measures: {
19
+ ...dataset.measures,
20
+ [COUNT]: measure2.count({ label: "Rows" })
21
+ },
22
+ dimensions: {
23
+ ...dataset.dimensions,
24
+ ...Object.fromEntries(Object.keys(dataset.time).map((time) => [
25
+ dayKey(time),
26
+ dimension2.timeBucket(time, "day")
27
+ ]))
28
+ }
29
+ };
30
+ const analytics = createAnalytics2({
31
+ datasets: [probe],
32
+ sources: { main: options.source },
33
+ tenant: (ctx) => ctx.tenant,
34
+ timezone: () => timezone,
35
+ ...options.now && { now: options.now }
36
+ });
37
+ const run = async (query, tenant = options.tenant) => {
38
+ const input = { dataset: dataset.key, period, limit: 100, ...query };
39
+ return await analytics.query(input, {
40
+ tenant
41
+ });
42
+ };
43
+ const checks = [];
44
+ const check = async (name, body) => {
45
+ try {
46
+ const problem = await body();
47
+ checks.push({ name, ok: problem === undefined, message: problem });
48
+ } catch (error) {
49
+ checks.push({
50
+ name,
51
+ ok: false,
52
+ message: error instanceof Error ? error.message : String(error)
53
+ });
54
+ }
55
+ };
56
+ const total = (await run({ measures: [COUNT] })).totals[COUNT] ?? 0;
57
+ if (total === 0) {
58
+ checks.push({
59
+ name: "seeded rows",
60
+ ok: false,
61
+ message: `Tenant ${options.tenant} has no rows in the period; the checks below prove little`
62
+ });
63
+ }
64
+ for (const key of Object.keys(dataset.measures)) {
65
+ await check(`measure ${key}`, async () => {
66
+ const value = (await run({ measures: [key] })).totals[key];
67
+ if (value !== null && typeof value !== "number")
68
+ return `returned ${String(value)}`;
69
+ if (typeof value === "number" && Number.isNaN(value))
70
+ return "returned NaN";
71
+ return;
72
+ });
73
+ }
74
+ const dimensions = Object.entries(dataset.dimensions);
75
+ for (const [key, entry] of dimensions) {
76
+ if (entry.groupable) {
77
+ await check(`group by ${key}`, async () => {
78
+ const result = await run({ measures: [COUNT], groupBy: [key] });
79
+ if (entry.kind === "manyToMany" || result.truncated)
80
+ return;
81
+ const sum = result.rows.reduce((acc, row) => acc + Number(row[COUNT] ?? 0), 0);
82
+ return sum === total ? undefined : `groups add up to ${sum}, the total is ${total}`;
83
+ });
84
+ }
85
+ if (entry.filterable) {
86
+ await check(`filter ${key} by emptiness`, async () => {
87
+ const empty = await run({
88
+ measures: [COUNT],
89
+ filters: [{ dimension: key, op: "isEmpty" }]
90
+ });
91
+ const notEmpty = await run({
92
+ measures: [COUNT],
93
+ filters: [{ dimension: key, op: "isNotEmpty" }]
94
+ });
95
+ const sum = (empty.totals[COUNT] ?? 0) + (notEmpty.totals[COUNT] ?? 0);
96
+ return sum === total ? undefined : `isEmpty + isNotEmpty is ${sum}, the total is ${total}`;
97
+ });
98
+ }
99
+ if (entry.filterable && entry.groupable && entry.category === "open") {
100
+ await check(`filter ${key} by label`, async () => {
101
+ const grouped = await run({ measures: [COUNT], groupBy: [key] });
102
+ const first = grouped.rows.find((row) => {
103
+ const value = row[key];
104
+ return typeof value === "object" && value !== null && value.key !== null;
105
+ });
106
+ const value = first?.[key];
107
+ if (!first || typeof value !== "object" || value === null)
108
+ return;
109
+ const filtered = await run({
110
+ measures: [COUNT],
111
+ filters: [{ dimension: key, op: "in", values: [value.label] }]
112
+ });
113
+ const expected = Number(first[COUNT]);
114
+ const actual = filtered.totals[COUNT];
115
+ return actual === expected ? undefined : `filtering by the label "${value.label}" counts ${actual}, its group counts ${expected}`;
116
+ });
117
+ }
118
+ if (entry.filterable && entry.category === "closed") {
119
+ await check(`filter ${key} by value`, async () => {
120
+ const [first] = Object.keys(entry.kind === "enum" || entry.kind === "computed" ? entry.values : {});
121
+ if (first === undefined)
122
+ return "lists no values";
123
+ await run({
124
+ measures: [COUNT],
125
+ filters: [{ dimension: key, op: "in", values: [first] }]
126
+ });
127
+ return;
128
+ });
129
+ }
130
+ }
131
+ await check("tenant isolation", async () => {
132
+ const measures = Object.keys(probe.measures);
133
+ const leaked = [];
134
+ for (let start = 0;start < measures.length; start += MAX_MEASURES) {
135
+ const batch = measures.slice(start, start + MAX_MEASURES);
136
+ const other = await run({ measures: batch }, emptyTenant);
137
+ leaked.push(...Object.entries(other.totals).filter(([, value]) => value !== 0 && value !== null));
138
+ }
139
+ if (leaked.length > 0) {
140
+ return `an empty tenant sees ${leaked.map(([key, value]) => `${key}=${value}`).join(", ")}`;
141
+ }
142
+ for (const [key, entry] of dimensions) {
143
+ if (!entry.groupable)
144
+ continue;
145
+ const grouped = await run({ measures: [COUNT], groupBy: [key] }, emptyTenant);
146
+ if (grouped.rows.length > 0)
147
+ return `an empty tenant sees ${grouped.rows.length} ${key} groups`;
148
+ }
149
+ return;
150
+ });
151
+ for (const time of Object.keys(dataset.time)) {
152
+ await check(`day buckets of ${time} in ${timezone}`, async () => {
153
+ const days = await run({
154
+ measures: [COUNT],
155
+ groupBy: [dayKey(time)],
156
+ time,
157
+ sort: { by: COUNT, direction: "desc" }
158
+ });
159
+ for (const row of days.rows.slice(0, 5)) {
160
+ const bucket = row[dayKey(time)];
161
+ if (typeof bucket !== "object" || bucket === null || typeof bucket.key !== "string")
162
+ continue;
163
+ const day = await run({
164
+ measures: [COUNT],
165
+ time,
166
+ period: { from: bucket.key, to: bucket.key }
167
+ });
168
+ if (day.totals[COUNT] !== row[COUNT]) {
169
+ return `the ${bucket.key} bucket counts ${row[COUNT]}, the period ${bucket.key} counts ${day.totals[COUNT]}`;
170
+ }
171
+ }
172
+ return;
173
+ });
174
+ }
175
+ return { ok: checks.every((entry) => entry.ok), checks };
176
+ }
177
+ async function assertDatasetContract(options) {
178
+ const report = await checkDatasetContract(options);
179
+ const failed = report.checks.filter((entry) => !entry.ok);
180
+ if (failed.length > 0) {
181
+ const lines = failed.map((entry) => `- ${entry.name}: ${entry.message}`);
182
+ throw new Error(`Dataset ${options.dataset.key} breaks the contract:
183
+ ${lines.join(`
184
+ `)}`);
185
+ }
186
+ return report;
187
+ }
188
+ export {
189
+ assertDatasetContract,
190
+ checkDatasetContract
191
+ };
package/package.json ADDED
@@ -0,0 +1,84 @@
1
+ {
2
+ "name": "measurr",
3
+ "version": "0.1.0",
4
+ "description": "A small typed semantic layer: datasets of measures and dimensions compiled to one SQL statement per dialect.",
5
+ "type": "module",
6
+ "module": "./dist/index.js",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "license": "MIT",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/HansKristoffer/measurr.git"
13
+ },
14
+ "homepage": "https://github.com/HansKristoffer/measurr#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/HansKristoffer/measurr/issues"
17
+ },
18
+ "author": "Kristoffer Hansen",
19
+ "keywords": [
20
+ "typescript",
21
+ "semantic-layer",
22
+ "analytics",
23
+ "sql",
24
+ "postgres",
25
+ "zod",
26
+ "llm-tools"
27
+ ],
28
+ "files": [
29
+ "src/**/*.ts",
30
+ "dist",
31
+ "!src/**/*.test.ts",
32
+ "!src/fixtures"
33
+ ],
34
+ "sideEffects": false,
35
+ "exports": {
36
+ ".": {
37
+ "types": "./dist/index.d.ts",
38
+ "bun": "./src/index.ts",
39
+ "import": "./dist/index.js",
40
+ "default": "./dist/index.js"
41
+ },
42
+ "./postgres": {
43
+ "types": "./dist/dialects/postgres.d.ts",
44
+ "bun": "./src/dialects/postgres.ts",
45
+ "import": "./dist/dialects/postgres.js",
46
+ "default": "./dist/dialects/postgres.js"
47
+ },
48
+ "./testing": {
49
+ "types": "./dist/testing.d.ts",
50
+ "bun": "./src/testing.ts",
51
+ "import": "./dist/testing.js",
52
+ "default": "./dist/testing.js"
53
+ }
54
+ },
55
+ "scripts": {
56
+ "build": "rm -rf dist && bun run build:js && bun run build:types",
57
+ "build:js": "bun build ./src/index.ts ./src/dialects/postgres.ts ./src/testing.ts --root ./src --outdir ./dist --target node --packages external --splitting",
58
+ "build:types": "tsgo -p tsconfig.build.json",
59
+ "prepublishOnly": "bun run build",
60
+ "test": "bun test",
61
+ "lint": "bun run typecheck && biome check",
62
+ "lint:write": "bun run typecheck && biome check --write",
63
+ "typecheck": "tsgo --noEmit",
64
+ "prepare": "husky",
65
+ "verify:package": "node scripts/verify-package.mjs"
66
+ },
67
+ "devDependencies": {
68
+ "@biomejs/biome": "^2.5.15",
69
+ "@types/bun": "latest",
70
+ "@typescript/native-preview": "^7.0.0-dev.20260306.1",
71
+ "husky": "^9.1.7",
72
+ "zod": "^4.3.6"
73
+ },
74
+ "peerDependencies": {
75
+ "typescript": ">=6.0.0",
76
+ "zod": "^4.0.0"
77
+ },
78
+ "peerDependenciesMeta": {
79
+ "typescript": {
80
+ "optional": true
81
+ }
82
+ },
83
+ "packageManager": "bun@1.4.2"
84
+ }