@gurulu/cli 0.4.7 → 1.0.1
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 +92 -0
- package/README.md +35 -106
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +25751 -0
- package/dist/commands/auth.d.ts +23 -20
- package/dist/commands/auth.d.ts.map +1 -0
- package/dist/commands/doctor.d.ts +20 -6
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/init.d.ts +33 -11
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/pull.d.ts +13 -0
- package/dist/commands/pull.d.ts.map +1 -0
- package/dist/commands/push.d.ts +40 -0
- package/dist/commands/push.d.ts.map +1 -0
- package/dist/commands/validate.d.ts +36 -0
- package/dist/commands/validate.d.ts.map +1 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25326 -876
- package/dist/lib/api.d.ts +139 -0
- package/dist/lib/api.d.ts.map +1 -0
- package/dist/lib/codegen.d.ts +4 -0
- package/dist/lib/codegen.d.ts.map +1 -0
- package/dist/lib/config.d.ts +43 -0
- package/dist/lib/config.d.ts.map +1 -0
- package/dist/lib/detect.d.ts +27 -0
- package/dist/lib/detect.d.ts.map +1 -0
- package/dist/lib/detect.js +106 -0
- package/dist/lib/exec-install.d.ts +21 -0
- package/dist/lib/exec-install.d.ts.map +1 -0
- package/dist/lib/install-plan.d.ts +25 -0
- package/dist/lib/install-plan.d.ts.map +1 -0
- package/dist/lib/install-plan.js +161 -0
- package/package.json +51 -20
- package/bin/gurulu.js +0 -2
- package/dist/api-client.d.ts +0 -33
- package/dist/api-client.js +0 -175
- package/dist/commands/add-server.d.ts +0 -9
- package/dist/commands/add-server.js +0 -162
- package/dist/commands/alerts.d.ts +0 -27
- package/dist/commands/alerts.js +0 -309
- package/dist/commands/api-keys.d.ts +0 -20
- package/dist/commands/api-keys.js +0 -130
- package/dist/commands/attribution.d.ts +0 -22
- package/dist/commands/attribution.js +0 -111
- package/dist/commands/audiences.d.ts +0 -23
- package/dist/commands/audiences.js +0 -243
- package/dist/commands/audit.d.ts +0 -20
- package/dist/commands/audit.js +0 -130
- package/dist/commands/auth.js +0 -249
- package/dist/commands/chat.d.ts +0 -19
- package/dist/commands/chat.js +0 -118
- package/dist/commands/config.d.ts +0 -10
- package/dist/commands/config.js +0 -92
- package/dist/commands/consent.d.ts +0 -27
- package/dist/commands/consent.js +0 -233
- package/dist/commands/conversion-paths.d.ts +0 -19
- package/dist/commands/conversion-paths.js +0 -55
- package/dist/commands/db.d.ts +0 -25
- package/dist/commands/db.js +0 -330
- package/dist/commands/destinations.d.ts +0 -20
- package/dist/commands/destinations.js +0 -191
- package/dist/commands/doctor.js +0 -360
- package/dist/commands/errors.d.ts +0 -27
- package/dist/commands/errors.js +0 -121
- package/dist/commands/events.d.ts +0 -33
- package/dist/commands/events.js +0 -371
- package/dist/commands/experiments.d.ts +0 -22
- package/dist/commands/experiments.js +0 -264
- package/dist/commands/funnels.d.ts +0 -17
- package/dist/commands/funnels.js +0 -203
- package/dist/commands/goals.d.ts +0 -18
- package/dist/commands/goals.js +0 -214
- package/dist/commands/heatmap.d.ts +0 -27
- package/dist/commands/heatmap.js +0 -112
- package/dist/commands/identity.d.ts +0 -29
- package/dist/commands/identity.js +0 -328
- package/dist/commands/init.js +0 -215
- package/dist/commands/insights.d.ts +0 -10
- package/dist/commands/insights.js +0 -77
- package/dist/commands/install.d.ts +0 -259
- package/dist/commands/install.js +0 -1590
- package/dist/commands/login.d.ts +0 -20
- package/dist/commands/login.js +0 -170
- package/dist/commands/logout.d.ts +0 -10
- package/dist/commands/logout.js +0 -41
- package/dist/commands/playground.d.ts +0 -11
- package/dist/commands/playground.js +0 -47
- package/dist/commands/releases.d.ts +0 -17
- package/dist/commands/releases.js +0 -54
- package/dist/commands/replay.d.ts +0 -18
- package/dist/commands/replay.js +0 -64
- package/dist/commands/secrets.d.ts +0 -19
- package/dist/commands/secrets.js +0 -145
- package/dist/commands/setup.d.ts +0 -21
- package/dist/commands/setup.js +0 -67
- package/dist/commands/sites.d.ts +0 -18
- package/dist/commands/sites.js +0 -139
- package/dist/commands/skad.d.ts +0 -18
- package/dist/commands/skad.js +0 -53
- package/dist/commands/sourcemap.d.ts +0 -33
- package/dist/commands/sourcemap.js +0 -204
- package/dist/commands/status.d.ts +0 -7
- package/dist/commands/status.js +0 -136
- package/dist/commands/upgrade.d.ts +0 -21
- package/dist/commands/upgrade.js +0 -183
- package/dist/commands/warehouse.d.ts +0 -20
- package/dist/commands/warehouse.js +0 -65
- package/dist/commands/warehouses.d.ts +0 -17
- package/dist/commands/warehouses.js +0 -182
- package/dist/commands/watch.d.ts +0 -45
- package/dist/commands/watch.js +0 -258
- package/dist/commands/whoami.d.ts +0 -9
- package/dist/commands/whoami.js +0 -50
- package/dist/config.d.ts +0 -75
- package/dist/config.js +0 -329
- package/dist/frameworks/detect.d.ts +0 -8
- package/dist/frameworks/detect.js +0 -458
- package/dist/install-intent-proposal.d.ts +0 -99
- package/dist/install-intent-proposal.js +0 -202
- package/dist/utils/api.d.ts +0 -20
- package/dist/utils/api.js +0 -47
- package/dist/utils/config.d.ts +0 -13
- package/dist/utils/config.js +0 -30
- package/dist/utils/confirm.d.ts +0 -17
- package/dist/utils/confirm.js +0 -40
- package/dist/utils/dry-run.d.ts +0 -20
- package/dist/utils/dry-run.js +0 -67
- package/dist/utils/from-file.d.ts +0 -9
- package/dist/utils/from-file.js +0 -72
- package/dist/utils/redact.d.ts +0 -14
- package/dist/utils/redact.js +0 -48
- package/dist/utils/ui.d.ts +0 -14
- package/dist/utils/ui.js +0 -59
- package/scripts/.gitkeep +0 -0
- package/scripts/README-gurulu-agentic-install.md +0 -114
- package/scripts/README-gurulu-scan.md +0 -98
- package/scripts/audit-cli-scopes.mjs +0 -204
- package/scripts/backfill-tenant-id.mjs +0 -172
- package/scripts/backfill-tenant-links.ts +0 -252
- package/scripts/backup-clickhouse.sh +0 -27
- package/scripts/backup-postgres.sh +0 -19
- package/scripts/bootstrap-runtime-schema.mjs +0 -87
- package/scripts/bootstrap-stripe.mjs +0 -158
- package/scripts/gurulu-agentic-install.lib.cjs +0 -762
- package/scripts/gurulu-agentic-install.mjs +0 -623
- package/scripts/gurulu-scan.lib.cjs +0 -1509
- package/scripts/gurulu-scan.mjs +0 -91
- package/scripts/gurulu-verify-install.lib.cjs +0 -334
- package/scripts/gurulu-verify-install.mjs +0 -59
- package/scripts/init-ssl.sh +0 -26
- package/scripts/migrate-flow-graph-enums.sh +0 -86
- package/scripts/monitor-disk.sh +0 -24
- package/scripts/patches/astro.patch.cjs +0 -74
- package/scripts/patches/auto-instrument/ast-helper.cjs +0 -480
- package/scripts/patches/auto-instrument/astro.cjs +0 -273
- package/scripts/patches/auto-instrument/express.cjs +0 -383
- package/scripts/patches/auto-instrument/fastify.cjs +0 -262
- package/scripts/patches/auto-instrument/hono.cjs +0 -392
- package/scripts/patches/auto-instrument/index.cjs +0 -80
- package/scripts/patches/auto-instrument/nestjs.cjs +0 -286
- package/scripts/patches/auto-instrument/nextjs-app-router.cjs +0 -345
- package/scripts/patches/auto-instrument/nextjs-pages.cjs +0 -361
- package/scripts/patches/auto-instrument/remix.cjs +0 -168
- package/scripts/patches/auto-instrument/sdk-helper-map.cjs +0 -241
- package/scripts/patches/auto-instrument/singleton-helper.cjs +0 -193
- package/scripts/patches/auto-instrument/sveltekit.cjs +0 -161
- package/scripts/patches/auto-instrument/vite-react.cjs +0 -37
- package/scripts/patches/auto-instrument/vue.cjs +0 -196
- package/scripts/patches/express.patch.cjs +0 -99
- package/scripts/patches/fastify.patch.cjs +0 -108
- package/scripts/patches/index.cjs +0 -300
- package/scripts/patches/nestjs.patch.cjs +0 -112
- package/scripts/patches/nextjs-app-router.patch.cjs +0 -97
- package/scripts/patches/nextjs-pages.patch.cjs +0 -97
- package/scripts/patches/remix.patch.cjs +0 -75
- package/scripts/patches/sveltekit.patch.cjs +0 -72
- package/scripts/patches/vite-react.patch.cjs +0 -73
- package/scripts/patches/vue.patch.cjs +0 -82
- package/scripts/renew-ssl.sh +0 -14
- package/scripts/resolve-migration.sh +0 -23
- package/scripts/seed-cli-dev-keys.mjs +0 -130
- package/scripts/seed-test-data.mjs +0 -391
- package/scripts/spike-browserless.ts +0 -65
- package/scripts/tenant-pivot-consistency-check.mjs +0 -205
- package/scripts/tenant-pivot-phase-3-cleanup.lib.cjs +0 -258
- package/scripts/tenant-pivot-phase-3-cleanup.mjs +0 -98
- package/scripts/test-identity-resolution.ts +0 -804
- package/scripts/validate-gurulu-schemas.mjs +0 -79
|
@@ -1,391 +0,0 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto';
|
|
2
|
-
|
|
3
|
-
const CLICKHOUSE_URL = process.env.CLICKHOUSE_URL || 'http://localhost:8123';
|
|
4
|
-
const SITE_ID = 'cmnuiqwnt0003dfu9tqt81l9d';
|
|
5
|
-
const NUM_USERS = 25;
|
|
6
|
-
const DAYS_BACK = 7;
|
|
7
|
-
|
|
8
|
-
// ---------------------------------------------------------------------------
|
|
9
|
-
// Reference data
|
|
10
|
-
// ---------------------------------------------------------------------------
|
|
11
|
-
|
|
12
|
-
const PAGES = [
|
|
13
|
-
{ url: '/', title: 'Gurulu - Analytics Platform' },
|
|
14
|
-
{ url: '/pricing', title: 'Pricing - Gurulu' },
|
|
15
|
-
{ url: '/blog', title: 'Blog - Gurulu' },
|
|
16
|
-
{ url: '/blog/getting-started', title: 'Getting Started with Gurulu' },
|
|
17
|
-
{ url: '/blog/privacy-first-analytics', title: 'Privacy-First Analytics' },
|
|
18
|
-
{ url: '/docs', title: 'Documentation - Gurulu' },
|
|
19
|
-
{ url: '/docs/installation', title: 'Installation Guide - Gurulu' },
|
|
20
|
-
{ url: '/docs/api-reference', title: 'API Reference - Gurulu' },
|
|
21
|
-
{ url: '/login', title: 'Login - Gurulu' },
|
|
22
|
-
{ url: '/dashboard', title: 'Dashboard - Gurulu' },
|
|
23
|
-
{ url: '/features', title: 'Features - Gurulu' },
|
|
24
|
-
{ url: '/about', title: 'About Us - Gurulu' },
|
|
25
|
-
{ url: '/contact', title: 'Contact - Gurulu' },
|
|
26
|
-
];
|
|
27
|
-
|
|
28
|
-
const EVENT_TYPES = ['pageview', 'click', 'scroll_depth', 'custom'];
|
|
29
|
-
const CUSTOM_EVENT_NAMES = [
|
|
30
|
-
'signup',
|
|
31
|
-
'purchase',
|
|
32
|
-
'download',
|
|
33
|
-
'contact_form',
|
|
34
|
-
'newsletter_subscribe',
|
|
35
|
-
];
|
|
36
|
-
|
|
37
|
-
const UTM_SOURCES = [
|
|
38
|
-
{ source: '', medium: '', campaign: '' }, // direct
|
|
39
|
-
{ source: '', medium: '', campaign: '' }, // direct (weighted)
|
|
40
|
-
{ source: 'google', medium: 'organic', campaign: '' },
|
|
41
|
-
{ source: 'google', medium: 'cpc', campaign: 'spring_2026' },
|
|
42
|
-
{ source: 'google', medium: 'cpc', campaign: 'brand_awareness' },
|
|
43
|
-
{ source: 'twitter', medium: 'social', campaign: 'launch_tweet' },
|
|
44
|
-
{ source: 'linkedin', medium: 'social', campaign: 'b2b_outreach' },
|
|
45
|
-
{ source: 'newsletter', medium: 'email', campaign: 'weekly_digest' },
|
|
46
|
-
{ source: 'newsletter', medium: 'email', campaign: 'product_update' },
|
|
47
|
-
{ source: 'producthunt', medium: 'referral', campaign: '' },
|
|
48
|
-
{ source: 'reddit', medium: 'social', campaign: '' },
|
|
49
|
-
];
|
|
50
|
-
|
|
51
|
-
const REFERRERS = [
|
|
52
|
-
'',
|
|
53
|
-
'',
|
|
54
|
-
'https://www.google.com/',
|
|
55
|
-
'https://www.google.com/search?q=analytics+platform',
|
|
56
|
-
'https://twitter.com/',
|
|
57
|
-
'https://www.linkedin.com/feed/',
|
|
58
|
-
'https://news.ycombinator.com/',
|
|
59
|
-
'https://www.reddit.com/r/analytics/',
|
|
60
|
-
'https://www.producthunt.com/',
|
|
61
|
-
'https://mail.google.com/',
|
|
62
|
-
];
|
|
63
|
-
|
|
64
|
-
const DEVICES = [
|
|
65
|
-
{ type: 'desktop', width: 1920, height: 1080 },
|
|
66
|
-
{ type: 'desktop', width: 2560, height: 1440 },
|
|
67
|
-
{ type: 'desktop', width: 1440, height: 900 },
|
|
68
|
-
{ type: 'mobile', width: 390, height: 844 },
|
|
69
|
-
{ type: 'mobile', width: 412, height: 915 },
|
|
70
|
-
{ type: 'mobile', width: 375, height: 812 },
|
|
71
|
-
{ type: 'tablet', width: 820, height: 1180 },
|
|
72
|
-
{ type: 'tablet', width: 1024, height: 1366 },
|
|
73
|
-
];
|
|
74
|
-
|
|
75
|
-
const BROWSERS = ['Chrome', 'Firefox', 'Safari', 'Edge', 'Arc'];
|
|
76
|
-
const OSES = ['Windows', 'macOS', 'Linux', 'iOS', 'Android'];
|
|
77
|
-
const COUNTRIES = ['US', 'TR', 'GB', 'DE', 'FR', 'NL', 'CA', 'AU', 'JP', 'BR'];
|
|
78
|
-
const CITIES_BY_COUNTRY = {
|
|
79
|
-
US: ['New York', 'San Francisco', 'Chicago', 'Austin', 'Seattle'],
|
|
80
|
-
TR: ['Istanbul', 'Ankara', 'Izmir'],
|
|
81
|
-
GB: ['London', 'Manchester', 'Edinburgh'],
|
|
82
|
-
DE: ['Berlin', 'Munich', 'Hamburg'],
|
|
83
|
-
FR: ['Paris', 'Lyon', 'Marseille'],
|
|
84
|
-
NL: ['Amsterdam', 'Rotterdam'],
|
|
85
|
-
CA: ['Toronto', 'Vancouver'],
|
|
86
|
-
AU: ['Sydney', 'Melbourne'],
|
|
87
|
-
JP: ['Tokyo', 'Osaka'],
|
|
88
|
-
BR: ['Sao Paulo', 'Rio de Janeiro'],
|
|
89
|
-
};
|
|
90
|
-
const LANGUAGES = ['en-US', 'tr-TR', 'en-GB', 'de-DE', 'fr-FR', 'ja-JP', 'pt-BR'];
|
|
91
|
-
|
|
92
|
-
// ---------------------------------------------------------------------------
|
|
93
|
-
// Helpers
|
|
94
|
-
// ---------------------------------------------------------------------------
|
|
95
|
-
|
|
96
|
-
function pick(arr) {
|
|
97
|
-
return arr[Math.floor(Math.random() * arr.length)];
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function weightedPick(arr, weights) {
|
|
101
|
-
const total = weights.reduce((a, b) => a + b, 0);
|
|
102
|
-
let r = Math.random() * total;
|
|
103
|
-
for (let i = 0; i < arr.length; i++) {
|
|
104
|
-
r -= weights[i];
|
|
105
|
-
if (r <= 0) return arr[i];
|
|
106
|
-
}
|
|
107
|
-
return arr[arr.length - 1];
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function randInt(min, max) {
|
|
111
|
-
return Math.floor(Math.random() * (max - min + 1)) + min;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/** Format Date to ClickHouse DateTime64(3) — no T, no Z, space-separated. */
|
|
115
|
-
function formatCH(date) {
|
|
116
|
-
const y = date.getUTCFullYear();
|
|
117
|
-
const mo = String(date.getUTCMonth() + 1).padStart(2, '0');
|
|
118
|
-
const d = String(date.getUTCDate()).padStart(2, '0');
|
|
119
|
-
const h = String(date.getUTCHours()).padStart(2, '0');
|
|
120
|
-
const mi = String(date.getUTCMinutes()).padStart(2, '0');
|
|
121
|
-
const s = String(date.getUTCSeconds()).padStart(2, '0');
|
|
122
|
-
const ms = String(date.getUTCMilliseconds()).padStart(3, '0');
|
|
123
|
-
return `${y}-${mo}-${d} ${h}:${mi}:${s}.${ms}`;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// ---------------------------------------------------------------------------
|
|
127
|
-
// User profile generation
|
|
128
|
-
// ---------------------------------------------------------------------------
|
|
129
|
-
|
|
130
|
-
function createUserProfile(index) {
|
|
131
|
-
const device = pick(DEVICES);
|
|
132
|
-
const country = pick(COUNTRIES);
|
|
133
|
-
const city = pick(CITIES_BY_COUNTRY[country]);
|
|
134
|
-
const os =
|
|
135
|
-
device.type === 'mobile'
|
|
136
|
-
? pick(['iOS', 'Android'])
|
|
137
|
-
: device.type === 'tablet'
|
|
138
|
-
? pick(['iOS', 'Android'])
|
|
139
|
-
: pick(['Windows', 'macOS', 'Linux']);
|
|
140
|
-
const browser =
|
|
141
|
-
os === 'iOS' || os === 'macOS'
|
|
142
|
-
? pick(['Safari', 'Chrome', 'Arc'])
|
|
143
|
-
: pick(['Chrome', 'Firefox', 'Edge']);
|
|
144
|
-
|
|
145
|
-
return {
|
|
146
|
-
anonymous_id: randomUUID(),
|
|
147
|
-
canonical_id: index < 6 ? `user_${randomUUID().slice(0, 8)}` : '', // ~25% identified
|
|
148
|
-
device,
|
|
149
|
-
browser,
|
|
150
|
-
os,
|
|
151
|
-
country,
|
|
152
|
-
city,
|
|
153
|
-
language: pick(LANGUAGES),
|
|
154
|
-
};
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// ---------------------------------------------------------------------------
|
|
158
|
-
// Event generation
|
|
159
|
-
// ---------------------------------------------------------------------------
|
|
160
|
-
|
|
161
|
-
function generateSessionEvents(user, sessionStart) {
|
|
162
|
-
const events = [];
|
|
163
|
-
const session_id = randomUUID();
|
|
164
|
-
const utm = pick(UTM_SOURCES);
|
|
165
|
-
const referrer = utm.source
|
|
166
|
-
? pick(REFERRERS.filter((r) => r !== '')) || ''
|
|
167
|
-
: pick(['', '']);
|
|
168
|
-
|
|
169
|
-
// Each session has 1–12 pageviews + some interactions
|
|
170
|
-
const pageCount = randInt(1, 12);
|
|
171
|
-
let cursor = new Date(sessionStart);
|
|
172
|
-
|
|
173
|
-
for (let p = 0; p < pageCount; p++) {
|
|
174
|
-
const page = p === 0 ? pick(PAGES.slice(0, 5)) : pick(PAGES); // entry pages biased to top-level
|
|
175
|
-
|
|
176
|
-
// Pageview event
|
|
177
|
-
events.push(makeEvent(user, session_id, 'pageview', 'pageview', page, cursor, utm, referrer));
|
|
178
|
-
|
|
179
|
-
// Advance time 5–60s
|
|
180
|
-
cursor = new Date(cursor.getTime() + randInt(5000, 60000));
|
|
181
|
-
|
|
182
|
-
// Possibly add scroll depth (60% chance)
|
|
183
|
-
if (Math.random() < 0.6) {
|
|
184
|
-
const depth = weightedPick([25, 50, 75, 100], [3, 4, 2, 1]);
|
|
185
|
-
events.push(
|
|
186
|
-
makeEvent(user, session_id, 'scroll_depth', 'scroll_depth', page, cursor, utm, referrer, {
|
|
187
|
-
depth,
|
|
188
|
-
}),
|
|
189
|
-
);
|
|
190
|
-
cursor = new Date(cursor.getTime() + randInt(2000, 10000));
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
// Possibly add a click (40% chance)
|
|
194
|
-
if (Math.random() < 0.4) {
|
|
195
|
-
const clickTargets = ['cta_button', 'nav_link', 'pricing_toggle', 'faq_accordion', 'hero_cta'];
|
|
196
|
-
events.push(
|
|
197
|
-
makeEvent(user, session_id, 'click', 'click', page, cursor, utm, referrer, {
|
|
198
|
-
target: pick(clickTargets),
|
|
199
|
-
}),
|
|
200
|
-
);
|
|
201
|
-
cursor = new Date(cursor.getTime() + randInt(1000, 5000));
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
// On certain pages, add custom events
|
|
205
|
-
if (page.url === '/contact' && Math.random() < 0.3) {
|
|
206
|
-
events.push(
|
|
207
|
-
makeEvent(user, session_id, 'custom', 'contact_form', page, cursor, utm, referrer, {
|
|
208
|
-
form_type: 'inquiry',
|
|
209
|
-
}),
|
|
210
|
-
);
|
|
211
|
-
cursor = new Date(cursor.getTime() + randInt(2000, 5000));
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
if (page.url === '/pricing' && Math.random() < 0.15) {
|
|
215
|
-
events.push(
|
|
216
|
-
makeEvent(user, session_id, 'custom', 'purchase', page, cursor, utm, referrer, {
|
|
217
|
-
plan: pick(['starter', 'pro', 'enterprise']),
|
|
218
|
-
value: pick([29, 79, 199]),
|
|
219
|
-
}),
|
|
220
|
-
);
|
|
221
|
-
cursor = new Date(cursor.getTime() + randInt(2000, 5000));
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
if (page.url === '/login' && Math.random() < 0.4) {
|
|
225
|
-
events.push(
|
|
226
|
-
makeEvent(user, session_id, 'custom', 'signup', page, cursor, utm, referrer, {
|
|
227
|
-
method: pick(['email', 'google', 'github']),
|
|
228
|
-
}),
|
|
229
|
-
);
|
|
230
|
-
cursor = new Date(cursor.getTime() + randInt(2000, 5000));
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
if (page.url === '/docs' && Math.random() < 0.25) {
|
|
234
|
-
events.push(
|
|
235
|
-
makeEvent(user, session_id, 'custom', 'download', page, cursor, utm, referrer, {
|
|
236
|
-
asset: pick(['sdk_js', 'sdk_react', 'whitepaper']),
|
|
237
|
-
}),
|
|
238
|
-
);
|
|
239
|
-
cursor = new Date(cursor.getTime() + randInt(2000, 5000));
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
if (page.url === '/blog' && Math.random() < 0.2) {
|
|
243
|
-
events.push(
|
|
244
|
-
makeEvent(user, session_id, 'custom', 'newsletter_subscribe', page, cursor, utm, referrer, {
|
|
245
|
-
source: 'blog_sidebar',
|
|
246
|
-
}),
|
|
247
|
-
);
|
|
248
|
-
cursor = new Date(cursor.getTime() + randInt(2000, 5000));
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
// Advance between pages 3–90s
|
|
252
|
-
cursor = new Date(cursor.getTime() + randInt(3000, 90000));
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
return events;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
function makeEvent(user, session_id, event_type, event_name, page, timestamp, utm, referrer, props = {}) {
|
|
259
|
-
return {
|
|
260
|
-
site_id: SITE_ID,
|
|
261
|
-
event_id: randomUUID(),
|
|
262
|
-
session_id,
|
|
263
|
-
canonical_id: user.canonical_id,
|
|
264
|
-
anonymous_id: user.anonymous_id,
|
|
265
|
-
event_type,
|
|
266
|
-
event_name,
|
|
267
|
-
page_url: `https://gurulu.io${page.url}`,
|
|
268
|
-
page_title: page.title,
|
|
269
|
-
referrer,
|
|
270
|
-
utm_source: utm.source,
|
|
271
|
-
utm_medium: utm.medium,
|
|
272
|
-
utm_campaign: utm.campaign,
|
|
273
|
-
utm_term: '',
|
|
274
|
-
utm_content: '',
|
|
275
|
-
device_type: user.device.type,
|
|
276
|
-
browser: user.browser,
|
|
277
|
-
os: user.os,
|
|
278
|
-
country: user.country,
|
|
279
|
-
city: user.city,
|
|
280
|
-
language: user.language,
|
|
281
|
-
screen_width: user.device.width,
|
|
282
|
-
screen_height: user.device.height,
|
|
283
|
-
properties: JSON.stringify(props),
|
|
284
|
-
consent_level: pick(['necessary', 'analytics', 'full']),
|
|
285
|
-
timestamp: formatCH(timestamp),
|
|
286
|
-
received_at: formatCH(new Date(timestamp.getTime() + randInt(50, 500))),
|
|
287
|
-
is_bot: 0,
|
|
288
|
-
};
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
// ---------------------------------------------------------------------------
|
|
292
|
-
// Main
|
|
293
|
-
// ---------------------------------------------------------------------------
|
|
294
|
-
|
|
295
|
-
async function main() {
|
|
296
|
-
console.log('Generating test events...');
|
|
297
|
-
|
|
298
|
-
const now = Date.now();
|
|
299
|
-
const users = Array.from({ length: NUM_USERS }, (_, i) => createUserProfile(i));
|
|
300
|
-
const allEvents = [];
|
|
301
|
-
|
|
302
|
-
for (const user of users) {
|
|
303
|
-
// Each user has 1–5 sessions across the 7 days
|
|
304
|
-
const sessionCount = randInt(1, 5);
|
|
305
|
-
for (let s = 0; s < sessionCount; s++) {
|
|
306
|
-
const dayOffset = randInt(0, DAYS_BACK - 1);
|
|
307
|
-
const hourOffset = randInt(6, 23); // bias toward waking hours
|
|
308
|
-
const minuteOffset = randInt(0, 59);
|
|
309
|
-
|
|
310
|
-
const sessionStart = new Date(now - dayOffset * 86400000);
|
|
311
|
-
sessionStart.setUTCHours(hourOffset, minuteOffset, 0, 0);
|
|
312
|
-
|
|
313
|
-
const events = generateSessionEvents(user, sessionStart);
|
|
314
|
-
allEvents.push(...events);
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
// Sort by timestamp
|
|
319
|
-
allEvents.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
320
|
-
|
|
321
|
-
console.log(`Generated ${allEvents.length} events from ${NUM_USERS} users.`);
|
|
322
|
-
|
|
323
|
-
// Insert into ClickHouse in batches
|
|
324
|
-
const BATCH_SIZE = 200;
|
|
325
|
-
let inserted = 0;
|
|
326
|
-
|
|
327
|
-
for (let i = 0; i < allEvents.length; i += BATCH_SIZE) {
|
|
328
|
-
const batch = allEvents.slice(i, i + BATCH_SIZE);
|
|
329
|
-
const body = batch.map((e) => JSON.stringify(e)).join('\n');
|
|
330
|
-
|
|
331
|
-
const res = await fetch(
|
|
332
|
-
`${CLICKHOUSE_URL}/?database=gurulu&query=${encodeURIComponent('INSERT INTO events_raw FORMAT JSONEachRow')}`,
|
|
333
|
-
{
|
|
334
|
-
method: 'POST',
|
|
335
|
-
headers: { 'Content-Type': 'application/json' },
|
|
336
|
-
body,
|
|
337
|
-
},
|
|
338
|
-
);
|
|
339
|
-
|
|
340
|
-
if (!res.ok) {
|
|
341
|
-
const text = await res.text();
|
|
342
|
-
console.error(`ClickHouse error (batch ${Math.floor(i / BATCH_SIZE) + 1}):`, text);
|
|
343
|
-
process.exit(1);
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
inserted += batch.length;
|
|
347
|
-
process.stdout.write(`\rInserted ${inserted}/${allEvents.length} events`);
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
console.log('\n');
|
|
351
|
-
|
|
352
|
-
// Summary
|
|
353
|
-
const eventTypes = {};
|
|
354
|
-
const utmSources = {};
|
|
355
|
-
const deviceTypes = {};
|
|
356
|
-
const identifiedCount = allEvents.filter((e) => e.canonical_id).length;
|
|
357
|
-
const uniqueSessions = new Set(allEvents.map((e) => e.session_id)).size;
|
|
358
|
-
|
|
359
|
-
for (const e of allEvents) {
|
|
360
|
-
eventTypes[e.event_name] = (eventTypes[e.event_name] || 0) + 1;
|
|
361
|
-
if (e.utm_source) utmSources[e.utm_source] = (utmSources[e.utm_source] || 0) + 1;
|
|
362
|
-
else utmSources['(direct)'] = (utmSources['(direct)'] || 0) + 1;
|
|
363
|
-
deviceTypes[e.device_type] = (deviceTypes[e.device_type] || 0) + 1;
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
console.log('=== Seed Summary ===');
|
|
367
|
-
console.log(`Total events: ${allEvents.length}`);
|
|
368
|
-
console.log(`Unique users: ${NUM_USERS}`);
|
|
369
|
-
console.log(`Unique sessions: ${uniqueSessions}`);
|
|
370
|
-
console.log(`Identified: ${identifiedCount} events (${((identifiedCount / allEvents.length) * 100).toFixed(1)}%)`);
|
|
371
|
-
console.log(`Date range: ${allEvents[0].timestamp} → ${allEvents[allEvents.length - 1].timestamp}`);
|
|
372
|
-
console.log('\nEvent types:');
|
|
373
|
-
for (const [k, v] of Object.entries(eventTypes).sort((a, b) => b[1] - a[1])) {
|
|
374
|
-
console.log(` ${k.padEnd(24)} ${v}`);
|
|
375
|
-
}
|
|
376
|
-
console.log('\nUTM sources:');
|
|
377
|
-
for (const [k, v] of Object.entries(utmSources).sort((a, b) => b[1] - a[1])) {
|
|
378
|
-
console.log(` ${k.padEnd(24)} ${v}`);
|
|
379
|
-
}
|
|
380
|
-
console.log('\nDevice types:');
|
|
381
|
-
for (const [k, v] of Object.entries(deviceTypes).sort((a, b) => b[1] - a[1])) {
|
|
382
|
-
console.log(` ${k.padEnd(24)} ${v}`);
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
console.log('\nDone!');
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
main().catch((err) => {
|
|
389
|
-
console.error('Fatal error:', err);
|
|
390
|
-
process.exit(1);
|
|
391
|
-
});
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Phase 11 Track B — B1 spike script
|
|
3
|
-
*
|
|
4
|
-
* Run with: npx tsx scripts/spike-browserless.ts
|
|
5
|
-
*
|
|
6
|
-
* Prereq: `docker compose up -d browserless`. Connects to the local
|
|
7
|
-
* browserless service, loads https://example.com, captures a screenshot
|
|
8
|
-
* to /tmp/spike-browserless.png, then closes the session.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { chromium } from 'playwright-core';
|
|
12
|
-
import { statSync } from 'node:fs';
|
|
13
|
-
import {
|
|
14
|
-
getBrowserlessBaseUrl,
|
|
15
|
-
getBrowserlessToken,
|
|
16
|
-
spawnRemoteSession,
|
|
17
|
-
} from '../src/lib/playground/browserless';
|
|
18
|
-
|
|
19
|
-
const SCREENSHOT_PATH = '/tmp/spike-browserless.png';
|
|
20
|
-
const TARGET_URL = 'https://example.com';
|
|
21
|
-
const SESSION_ID = 'spike-test';
|
|
22
|
-
|
|
23
|
-
async function main() {
|
|
24
|
-
const startedAt = Date.now();
|
|
25
|
-
console.log('[spike] browserless base URL:', getBrowserlessBaseUrl());
|
|
26
|
-
console.log('[spike] using token prefix :', getBrowserlessToken().slice(0, 6) + '…');
|
|
27
|
-
|
|
28
|
-
console.log('[spike] spawning remote session…');
|
|
29
|
-
const session = await spawnRemoteSession({
|
|
30
|
-
url: TARGET_URL,
|
|
31
|
-
sessionId: SESSION_ID,
|
|
32
|
-
});
|
|
33
|
-
console.log('[spike] session ready:', {
|
|
34
|
-
sessionId: session.sessionId,
|
|
35
|
-
wsEndpoint: session.wsEndpoint,
|
|
36
|
-
startedAt: session.startedAt.toISOString(),
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
// Reconnect with the returned CDP endpoint to prove the contract: the
|
|
40
|
-
// caller can take the endpoint and drive Playwright on their own.
|
|
41
|
-
const browser = await chromium.connectOverCDP(session.wsEndpoint);
|
|
42
|
-
try {
|
|
43
|
-
const context = browser.contexts()[0] ?? (await browser.newContext());
|
|
44
|
-
const page = context.pages()[0] ?? (await context.newPage());
|
|
45
|
-
console.log('[spike] capturing screenshot →', SCREENSHOT_PATH);
|
|
46
|
-
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true });
|
|
47
|
-
} finally {
|
|
48
|
-
await browser.close().catch(() => {});
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
await session.close();
|
|
52
|
-
const size = statSync(SCREENSHOT_PATH).size;
|
|
53
|
-
const durationMs = Date.now() - startedAt;
|
|
54
|
-
if (size === 0) {
|
|
55
|
-
throw new Error('Screenshot file is empty');
|
|
56
|
-
}
|
|
57
|
-
console.log(
|
|
58
|
-
`[spike] ✓ success — screenshot ${size} bytes at ${SCREENSHOT_PATH} (${durationMs}ms)`,
|
|
59
|
-
);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
main().catch((err) => {
|
|
63
|
-
console.error('[spike] ✗ failed:', err);
|
|
64
|
-
process.exit(1);
|
|
65
|
-
});
|
|
@@ -1,205 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* Phase 16 B2 — Tenant pivot consistency check.
|
|
4
|
-
*
|
|
5
|
-
* For a given tenant, runs the analytics read path twice:
|
|
6
|
-
* 1. LEGACY: WHERE site_id IN (<sites from Prisma>)
|
|
7
|
-
* 2. TENANT: WHERE tenant_id = <tenantId>
|
|
8
|
-
* and diffs the row counts + a handful of key aggregates. Prints a report.
|
|
9
|
-
*
|
|
10
|
-
* Operators use this to validate that the tenant_id column on `events_raw` is
|
|
11
|
-
* fully backfilled and consistent with the Prisma site→tenant mapping before
|
|
12
|
-
* flipping `ANALYTICS_TENANT_READ_PATH=1` in prod.
|
|
13
|
-
*
|
|
14
|
-
* Usage:
|
|
15
|
-
* node scripts/tenant-pivot-consistency-check.mjs --tenant-id <id> \
|
|
16
|
-
* [--from <iso>] [--to <iso>]
|
|
17
|
-
*
|
|
18
|
-
* Environment:
|
|
19
|
-
* CLICKHOUSE_URL (default http://localhost:8123)
|
|
20
|
-
* CLICKHOUSE_DATABASE (default gurulu)
|
|
21
|
-
*
|
|
22
|
-
* Safe to run in prod — read-only, no side effects.
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
// ---------------------------------------------------------------------------
|
|
26
|
-
// CLI arg parsing
|
|
27
|
-
// ---------------------------------------------------------------------------
|
|
28
|
-
|
|
29
|
-
function parseArgs(argv) {
|
|
30
|
-
const args = {};
|
|
31
|
-
for (let i = 2; i < argv.length; i++) {
|
|
32
|
-
const a = argv[i];
|
|
33
|
-
if (a === '--tenant-id') args.tenantId = argv[++i];
|
|
34
|
-
else if (a === '--from') args.from = argv[++i];
|
|
35
|
-
else if (a === '--to') args.to = argv[++i];
|
|
36
|
-
else if (a === '--help' || a === '-h') args.help = true;
|
|
37
|
-
}
|
|
38
|
-
return args;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function printHelp() {
|
|
42
|
-
console.log(
|
|
43
|
-
`\nUsage: node scripts/tenant-pivot-consistency-check.mjs --tenant-id <id> [--from <iso>] [--to <iso>]\n\n` +
|
|
44
|
-
` --tenant-id Required. The tenant id to audit.\n` +
|
|
45
|
-
` --from Optional. ISO timestamp for lower bound (default: 7d ago).\n` +
|
|
46
|
-
` --to Optional. ISO timestamp for upper bound (default: now).\n`
|
|
47
|
-
);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// ---------------------------------------------------------------------------
|
|
51
|
-
// Diff helper — exported for unit tests.
|
|
52
|
-
// ---------------------------------------------------------------------------
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Compare two aggregate result bags and return a structured diff.
|
|
56
|
-
*
|
|
57
|
-
* Each bag is a flat object like:
|
|
58
|
-
* { events: 1234, visitors: 56, sessions: 78, pageviews: 910 }
|
|
59
|
-
*
|
|
60
|
-
* The diff returns:
|
|
61
|
-
* { ok: boolean, mismatches: Array<{key, legacy, tenant, absDelta, relDelta}> }
|
|
62
|
-
*
|
|
63
|
-
* A key is considered a mismatch when abs(legacy - tenant) > absTolerance AND
|
|
64
|
-
* abs((legacy - tenant) / legacy) > relTolerance (if legacy != 0). This keeps
|
|
65
|
-
* the check resilient to harmless rounding in floating-point aggregates.
|
|
66
|
-
*/
|
|
67
|
-
export function diffAggregates(legacy, tenant, opts = {}) {
|
|
68
|
-
const absTolerance = opts.absTolerance ?? 0;
|
|
69
|
-
const relTolerance = opts.relTolerance ?? 0;
|
|
70
|
-
|
|
71
|
-
const keys = new Set([...Object.keys(legacy || {}), ...Object.keys(tenant || {})]);
|
|
72
|
-
const mismatches = [];
|
|
73
|
-
|
|
74
|
-
for (const key of keys) {
|
|
75
|
-
const l = Number(legacy?.[key] ?? 0);
|
|
76
|
-
const t = Number(tenant?.[key] ?? 0);
|
|
77
|
-
const absDelta = Math.abs(l - t);
|
|
78
|
-
const relDelta = l !== 0 ? Math.abs((l - t) / l) : absDelta > 0 ? 1 : 0;
|
|
79
|
-
|
|
80
|
-
const overAbs = absDelta > absTolerance;
|
|
81
|
-
const overRel = relDelta > relTolerance;
|
|
82
|
-
if (overAbs && overRel) {
|
|
83
|
-
mismatches.push({ key, legacy: l, tenant: t, absDelta, relDelta });
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
return { ok: mismatches.length === 0, mismatches };
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// ---------------------------------------------------------------------------
|
|
91
|
-
// ClickHouse helper (mirrors src/lib/clickhouse.ts — kept inline so this
|
|
92
|
-
// script is `node`-runnable without a TS toolchain).
|
|
93
|
-
// ---------------------------------------------------------------------------
|
|
94
|
-
|
|
95
|
-
async function clickhouseQuery(query, params = {}) {
|
|
96
|
-
const CLICKHOUSE_URL = process.env.CLICKHOUSE_URL || 'http://localhost:8123';
|
|
97
|
-
const CLICKHOUSE_DB = process.env.CLICKHOUSE_DATABASE || 'gurulu';
|
|
98
|
-
|
|
99
|
-
const url = new URL(CLICKHOUSE_URL);
|
|
100
|
-
url.searchParams.set('database', CLICKHOUSE_DB);
|
|
101
|
-
url.searchParams.set('default_format', 'JSON');
|
|
102
|
-
for (const [k, v] of Object.entries(params)) {
|
|
103
|
-
let s = String(v);
|
|
104
|
-
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.*Z$/.test(s)) {
|
|
105
|
-
s = s.replace('T', ' ').replace('Z', '');
|
|
106
|
-
}
|
|
107
|
-
url.searchParams.set(`param_${k}`, s);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
const res = await fetch(url.toString(), {
|
|
111
|
-
method: 'POST',
|
|
112
|
-
body: query,
|
|
113
|
-
headers: { 'Content-Type': 'text/plain' },
|
|
114
|
-
});
|
|
115
|
-
if (!res.ok) throw new Error(`ClickHouse error ${res.status}: ${await res.text()}`);
|
|
116
|
-
return res.json();
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function escapeSiteId(s) {
|
|
120
|
-
const cleaned = String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
121
|
-
return `'${cleaned}'`;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// ---------------------------------------------------------------------------
|
|
125
|
-
// Main.
|
|
126
|
-
// ---------------------------------------------------------------------------
|
|
127
|
-
|
|
128
|
-
async function main() {
|
|
129
|
-
const args = parseArgs(process.argv);
|
|
130
|
-
if (args.help || !args.tenantId) {
|
|
131
|
-
printHelp();
|
|
132
|
-
if (!args.tenantId) process.exit(1);
|
|
133
|
-
return;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
const to = args.to || new Date().toISOString();
|
|
137
|
-
const from = args.from || new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
|
|
138
|
-
|
|
139
|
-
console.log(`[consistency-check] tenant=${args.tenantId} window=${from}..${to}`);
|
|
140
|
-
|
|
141
|
-
// Resolve site ids via Prisma — lazy-loaded so the test importing
|
|
142
|
-
// `diffAggregates` doesn't have to pay the Prisma client cost.
|
|
143
|
-
const { PrismaClient } = await import('@prisma/client');
|
|
144
|
-
const prisma = new PrismaClient();
|
|
145
|
-
const sites = await prisma.site.findMany({
|
|
146
|
-
where: { tenantId: args.tenantId },
|
|
147
|
-
select: { id: true },
|
|
148
|
-
});
|
|
149
|
-
await prisma.$disconnect();
|
|
150
|
-
|
|
151
|
-
if (sites.length === 0) {
|
|
152
|
-
console.error(`[consistency-check] no sites found for tenant ${args.tenantId}`);
|
|
153
|
-
process.exit(2);
|
|
154
|
-
}
|
|
155
|
-
const siteIds = sites.map((s) => s.id);
|
|
156
|
-
const inList = siteIds.map(escapeSiteId).join(',');
|
|
157
|
-
|
|
158
|
-
const AGG = `
|
|
159
|
-
SELECT
|
|
160
|
-
count() AS events,
|
|
161
|
-
uniqExact(anonymous_id) AS visitors,
|
|
162
|
-
uniqExact(session_id) AS sessions,
|
|
163
|
-
countIf(event_type = 'pageview') AS pageviews
|
|
164
|
-
`;
|
|
165
|
-
const WINDOW = `timestamp >= {from:DateTime64(3)} AND timestamp <= {to:DateTime64(3)} AND is_bot = 0`;
|
|
166
|
-
|
|
167
|
-
const [legacyRes, tenantRes] = await Promise.all([
|
|
168
|
-
clickhouseQuery(
|
|
169
|
-
`${AGG} FROM events_raw WHERE site_id IN (${inList}) AND ${WINDOW}`,
|
|
170
|
-
{ from, to }
|
|
171
|
-
),
|
|
172
|
-
clickhouseQuery(
|
|
173
|
-
`${AGG} FROM events_raw WHERE tenant_id = {tenantId:String} AND ${WINDOW}`,
|
|
174
|
-
{ from, to, tenantId: args.tenantId }
|
|
175
|
-
),
|
|
176
|
-
]);
|
|
177
|
-
|
|
178
|
-
const legacy = legacyRes.data[0] || {};
|
|
179
|
-
const tenant = tenantRes.data[0] || {};
|
|
180
|
-
|
|
181
|
-
console.log('[consistency-check] legacy path:', legacy);
|
|
182
|
-
console.log('[consistency-check] tenant path:', tenant);
|
|
183
|
-
|
|
184
|
-
const diff = diffAggregates(legacy, tenant);
|
|
185
|
-
if (diff.ok) {
|
|
186
|
-
console.log('[consistency-check] OK — both paths agree.');
|
|
187
|
-
process.exit(0);
|
|
188
|
-
}
|
|
189
|
-
console.error('[consistency-check] MISMATCH:');
|
|
190
|
-
for (const m of diff.mismatches) {
|
|
191
|
-
console.error(
|
|
192
|
-
` ${m.key}: legacy=${m.legacy} tenant=${m.tenant} (Δ=${m.absDelta}, rel=${(m.relDelta * 100).toFixed(2)}%)`
|
|
193
|
-
);
|
|
194
|
-
}
|
|
195
|
-
process.exit(3);
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// Only run main() when invoked directly, never when imported by tests.
|
|
199
|
-
const isMain = import.meta.url === `file://${process.argv[1]}`;
|
|
200
|
-
if (isMain) {
|
|
201
|
-
main().catch((err) => {
|
|
202
|
-
console.error('[consistency-check] fatal:', err);
|
|
203
|
-
process.exit(10);
|
|
204
|
-
});
|
|
205
|
-
}
|