create-pulsekit 1.1.0 → 1.2.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/README.md +10 -5
- package/dist/index.js +72 -12
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -20,23 +20,28 @@ npx create-pulsekit
|
|
|
20
20
|
2. **Validates** that you're in a Next.js project
|
|
21
21
|
3. **Installs** `@pulsekit/core`, `@pulsekit/next`, and `@pulsekit/react`
|
|
22
22
|
4. **Scaffolds** the analytics dashboard page and API routes
|
|
23
|
-
5. **
|
|
24
|
-
6. **Injects** the
|
|
25
|
-
7. **
|
|
23
|
+
5. **Scaffolds** a `vercel.json` with cron jobs for automatic data aggregation and cleanup
|
|
24
|
+
6. **Injects** the `<PulseTracker />` component into your root layout
|
|
25
|
+
7. **Injects** the error instrumentation for server-side error tracking
|
|
26
|
+
8. **Writes** the Supabase SQL migration file
|
|
26
27
|
|
|
27
28
|
## After Setup
|
|
28
29
|
|
|
29
|
-
1. Add your
|
|
30
|
+
1. Add your environment variables to `.env.local`:
|
|
30
31
|
```
|
|
31
32
|
NEXT_PUBLIC_SUPABASE_URL=<your-supabase-url>
|
|
32
33
|
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=<your-anon-key>
|
|
34
|
+
SUPABASE_SERVICE_ROLE_KEY=<your-service-role-key>
|
|
35
|
+
PULSE_SECRET=<a-secret-at-least-16-characters>
|
|
36
|
+
CRON_SECRET=<a-random-string-for-cron-auth>
|
|
33
37
|
```
|
|
34
38
|
2. Link and push the database migration:
|
|
35
39
|
```bash
|
|
36
40
|
npx supabase link
|
|
37
41
|
npx supabase db push
|
|
38
42
|
```
|
|
39
|
-
3.
|
|
43
|
+
3. If deploying to Vercel, add `CRON_SECRET` to your project environment variables to enable automatic data aggregation and cleanup
|
|
44
|
+
4. Start your dev server and visit `/admin/analytics`
|
|
40
45
|
|
|
41
46
|
## License
|
|
42
47
|
|
package/dist/index.js
CHANGED
|
@@ -113,7 +113,7 @@ import { createClient } from "@supabase/supabase-js";
|
|
|
113
113
|
|
|
114
114
|
const supabase = createClient(
|
|
115
115
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
116
|
-
process.env.
|
|
116
|
+
process.env.SUPABASE_SERVICE_ROLE_KEY!
|
|
117
117
|
);
|
|
118
118
|
|
|
119
119
|
export const POST = createPulseHandler({
|
|
@@ -129,20 +129,24 @@ import { createClient } from "@supabase/supabase-js";
|
|
|
129
129
|
|
|
130
130
|
const supabase = createClient(
|
|
131
131
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
132
|
-
process.env.
|
|
132
|
+
process.env.SUPABASE_SERVICE_ROLE_KEY!
|
|
133
133
|
);
|
|
134
134
|
|
|
135
|
-
|
|
135
|
+
const handler = withPulseAuth(createRefreshHandler({ supabase }));
|
|
136
|
+
export const GET = handler;
|
|
137
|
+
export const POST = handler;
|
|
136
138
|
`;
|
|
137
139
|
const consolidateRoute = `import { createConsolidateHandler, withPulseAuth } from "@pulsekit/next";
|
|
138
140
|
import { createClient } from "@supabase/supabase-js";
|
|
139
141
|
|
|
140
142
|
const supabase = createClient(
|
|
141
143
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
142
|
-
process.env.
|
|
144
|
+
process.env.SUPABASE_SERVICE_ROLE_KEY!
|
|
143
145
|
);
|
|
144
146
|
|
|
145
|
-
|
|
147
|
+
const handler = withPulseAuth(createConsolidateHandler({ supabase }));
|
|
148
|
+
export const GET = handler;
|
|
149
|
+
export const POST = handler;
|
|
146
150
|
`;
|
|
147
151
|
const authRoute = `import { createPulseAuthHandler } from "@pulsekit/next";
|
|
148
152
|
|
|
@@ -155,32 +159,65 @@ export const DELETE = handler;
|
|
|
155
159
|
import { createClient } from "@supabase/supabase-js";
|
|
156
160
|
import { PulseDashboard, PulseAuthGate } from "@pulsekit/react";
|
|
157
161
|
import { getPulseTimezone } from "@pulsekit/next";
|
|
162
|
+
import type { Timeframe } from "@pulsekit/core";
|
|
158
163
|
import { Spinner } from "@/components/ui/spinner";
|
|
159
164
|
import "@pulsekit/react/pulse.css";
|
|
160
165
|
|
|
161
166
|
const supabase = createClient(
|
|
162
167
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
163
|
-
process.env.
|
|
168
|
+
process.env.SUPABASE_SERVICE_ROLE_KEY!
|
|
164
169
|
);
|
|
165
170
|
|
|
166
|
-
async function Dashboard(
|
|
171
|
+
async function Dashboard(props: {
|
|
172
|
+
timeframe: Timeframe;
|
|
173
|
+
range: "7d" | "30d";
|
|
174
|
+
tab: string;
|
|
175
|
+
eventType?: string;
|
|
176
|
+
eventPath?: string;
|
|
177
|
+
eventSession?: string;
|
|
178
|
+
eventPage?: number;
|
|
179
|
+
}) {
|
|
167
180
|
const timezone = await getPulseTimezone();
|
|
168
181
|
|
|
169
182
|
return (
|
|
170
183
|
<PulseDashboard
|
|
171
184
|
supabase={supabase}
|
|
172
185
|
siteId="default"
|
|
173
|
-
timeframe="7d"
|
|
174
186
|
timezone={timezone}
|
|
187
|
+
{...props}
|
|
175
188
|
/>
|
|
176
189
|
);
|
|
177
190
|
}
|
|
178
191
|
|
|
179
|
-
export default function AnalyticsPage(
|
|
192
|
+
export default async function AnalyticsPage({
|
|
193
|
+
searchParams,
|
|
194
|
+
}: {
|
|
195
|
+
searchParams: Promise<{
|
|
196
|
+
from?: string;
|
|
197
|
+
to?: string;
|
|
198
|
+
tab?: string;
|
|
199
|
+
range?: string;
|
|
200
|
+
eventType?: string;
|
|
201
|
+
eventPath?: string;
|
|
202
|
+
eventSession?: string;
|
|
203
|
+
eventPage?: string;
|
|
204
|
+
}>;
|
|
205
|
+
}) {
|
|
206
|
+
const params = await searchParams;
|
|
207
|
+
const timeframe: Timeframe = params.from && params.to ? { from: params.from, to: params.to } : "7d";
|
|
208
|
+
|
|
180
209
|
return (
|
|
181
210
|
<Suspense fallback={<div style={{ display: "flex", alignItems: "center", justifyContent: "center", minHeight: "100vh", padding: "1.5rem" }}><Spinner style={{ width: 24, height: 24 }} /></div>}>
|
|
182
211
|
<PulseAuthGate secret={process.env.PULSE_SECRET!}>
|
|
183
|
-
<Dashboard
|
|
212
|
+
<Dashboard
|
|
213
|
+
timeframe={timeframe}
|
|
214
|
+
range={params.range === "30d" ? "30d" : "7d"}
|
|
215
|
+
tab={params.tab || "traffic"}
|
|
216
|
+
eventType={params.eventType}
|
|
217
|
+
eventPath={params.eventPath}
|
|
218
|
+
eventSession={params.eventSession}
|
|
219
|
+
eventPage={params.eventPage ? parseInt(params.eventPage, 10) : undefined}
|
|
220
|
+
/>
|
|
184
221
|
</PulseAuthGate>
|
|
185
222
|
</Suspense>
|
|
186
223
|
);
|
|
@@ -264,6 +301,23 @@ export { Spinner }
|
|
|
264
301
|
import_node_fs2.default.writeFileSync(fullPath, content, "utf8");
|
|
265
302
|
console.log(` Created: ${rel}`);
|
|
266
303
|
}
|
|
304
|
+
const vercelJsonPath = import_node_path2.default.join(cwd, "vercel.json");
|
|
305
|
+
if (import_node_fs2.default.existsSync(vercelJsonPath)) {
|
|
306
|
+
console.log(" Skipped (already exists): vercel.json");
|
|
307
|
+
} else {
|
|
308
|
+
const vercelConfig = {
|
|
309
|
+
crons: [
|
|
310
|
+
{ path: "/api/pulse/refresh-aggregates", schedule: "0 */6 * * *" },
|
|
311
|
+
{ path: "/api/pulse/consolidate", schedule: "0 3 * * *" }
|
|
312
|
+
]
|
|
313
|
+
};
|
|
314
|
+
import_node_fs2.default.writeFileSync(
|
|
315
|
+
vercelJsonPath,
|
|
316
|
+
JSON.stringify(vercelConfig, null, 2) + "\n",
|
|
317
|
+
"utf8"
|
|
318
|
+
);
|
|
319
|
+
console.log(" Created: vercel.json (Vercel Cron for aggregation)");
|
|
320
|
+
}
|
|
267
321
|
console.log("");
|
|
268
322
|
}
|
|
269
323
|
|
|
@@ -424,7 +478,7 @@ async function injectInstrumentation() {
|
|
|
424
478
|
var import_node_fs5 = __toESM(require("fs"));
|
|
425
479
|
var import_node_path5 = __toESM(require("path"));
|
|
426
480
|
var SQL_MAP = JSON.parse(
|
|
427
|
-
`{"001_init_pulse.sql":"create schema if not exists analytics;\\n\\n-- Add analytics to the schemas exposed by PostgREST\\nalter role authenticator set pgrst.db_schemas = 'public, graphql_public, analytics';\\n\\n-- Schema-level access\\ngrant usage on schema analytics to anon, authenticated, service_role;\\nalter default privileges in schema analytics grant all on tables to anon, authenticated, service_role;\\n\\ncreate table if not exists analytics.pulse_events (\\n id bigserial primary key,\\n site_id text not null,\\n session_id text,\\n path text not null,\\n event_type text not null,\\n meta jsonb,\\n created_at timestamptz not null default now()\\n);\\n\\ncreate index if not exists idx_pulse_events_site_created_at\\n on analytics.pulse_events (site_id, created_at);\\n\\ncreate index if not exists idx_pulse_events_site_path_created_at\\n on analytics.pulse_events (site_id, path, created_at);\\n\\nalter table analytics.pulse_events enable row level security; \\n \\n-- Allow the anon key (API route) to insert events\\ndrop policy if exists \\"Allow anon insert on pulse_events\\" on analytics.pulse_events;\\ncreate policy \\"Allow anon insert on pulse_events\\"\\n on analytics.pulse_events\\n for insert\\n to anon\\n with check (true);\\n\\n-- Only authenticated users (dashboard) can read events\\ndrop policy if exists \\"Allow authenticated select on pulse_events\\" on analytics.pulse_events;\\ncreate policy \\"Allow authenticated select on pulse_events\\"\\n on analytics.pulse_events\\n for select\\n to authenticated\\n using (true);\\n\\ncreate table if not exists analytics.pulse_aggregates (\\n date date not null,\\n site_id text not null,\\n path text not null,\\n total_views integer not null default 0,\\n unique_visitors integer not null default 0,\\n primary key (date, site_id, path)\\n);\\n\\n-- Grant table-level access (must be after table creation)\\ngrant all on all tables in schema analytics to anon, authenticated, service_role;\\ngrant all on all sequences in schema analytics to anon, authenticated, service_role;\\n\\nalter table analytics.pulse_aggregates enable row level security;\\n\\n-- Allow reading aggregates (dashboard)\\ndrop policy if exists \\"Allow authenticated select on pulse_aggregates\\" on analytics.pulse_aggregates;\\ncreate policy \\"Allow authenticated select on pulse_aggregates\\"\\n on analytics.pulse_aggregates\\n for select\\n to authenticated\\n using (true);\\n\\ndrop policy if exists \\"Allow anon select on pulse_aggregates\\" on analytics.pulse_aggregates;\\ncreate policy \\"Allow anon select on pulse_aggregates\\"\\n on analytics.pulse_aggregates\\n for select\\n to anon\\n using (true);\\n\\n-- Reload PostgREST config and schema cache (must be last)\\nnotify pgrst, 'reload config';\\nnotify pgrst, 'reload schema';\\n","002_aggregation_function.sql":"-- Aggregation function: rolls up raw events into daily aggregates\\ncreate or replace function analytics.pulse_refresh_aggregates(days_back integer default 7)\\nreturns void\\nlanguage sql\\nsecurity definer\\nas $$\\n insert into analytics.pulse_aggregates (date, site_id, path, total_views, unique_visitors)\\n select\\n date_trunc('day', created_at)::date as date,\\n site_id,\\n path,\\n count(*) as total_views,\\n count(distinct session_id) as unique_visitors\\n from analytics.pulse_events\\n where created_at >= now() - (days_back || ' days')::interval\\n group by 1, 2, 3\\n on conflict (date, site_id, path) do update\\n set\\n total_views = excluded.total_views,\\n unique_visitors = excluded.unique_visitors;\\n$$;\\n\\n-- Allow all roles to execute the aggregation function\\n-- security definer ensures it runs with the owner's privileges regardless of caller\\ngrant execute on function analytics.pulse_refresh_aggregates(integer) to anon, authenticated, service_role;\\n","003_geo_and_timezone.sql":"-- Add geo columns to pulse_events\\nalter table analytics.pulse_events\\n add column if not exists country text,\\n add column if not exists region text,\\n add column if not exists city text,\\n add column if not exists timezone text,\\n add column if not exists latitude double precision,\\n add column if not exists longitude double precision;\\n\\n-- Timezone-aware stats: queries raw events with AT TIME ZONE\\n-- so the dashboard can display data bucketed by the viewer's local day.\\ncreate or replace function analytics.pulse_stats_by_timezone(\\n p_site_id text,\\n p_timezone text default 'UTC',\\n p_days_back integer default 7\\n)\\nreturns table (\\n date date,\\n path text,\\n total_views bigint,\\n unique_visitors bigint\\n)\\nlanguage sql\\nsecurity definer\\nstable\\nas $$\\n select\\n date_trunc('day', created_at at time zone p_timezone)::date as date,\\n path,\\n count(*) as total_views,\\n count(distinct session_id) as unique_visitors\\n from analytics.pulse_events\\n where site_id = p_site_id\\n and created_at >= now() - make_interval(days => p_days_back + 1)\\n group by 1, 2;\\n$$;\\n\\ngrant execute on function analytics.pulse_stats_by_timezone(text, text, integer)\\n to anon, authenticated, service_role;\\n\\n-- Drop first so return type can change (CREATE OR REPLACE cannot alter return columns)\\ndrop function if exists analytics.pulse_location_stats(text, integer);\\n\\n-- Location stats: visitor counts grouped by country + city, with averaged coordinates\\ncreate or replace function analytics.pulse_location_stats(\\n p_site_id text,\\n p_days_back integer default 7\\n)\\nreturns table (\\n country text,\\n city text,\\n latitude double precision,\\n longitude double precision,\\n total_views bigint,\\n unique_visitors bigint\\n)\\nlanguage sql\\nsecurity definer\\nstable\\nas $$\\n select\\n country,\\n city,\\n avg(latitude) as latitude,\\n avg(longitude) as longitude,\\n count(*) as total_views,\\n count(distinct session_id) as unique_visitors\\n from analytics.pulse_events\\n where site_id = p_site_id\\n and created_at >= now() - make_interval(days => p_days_back)\\n and country is not null\\n group by 1, 2\\n order by total_views desc;\\n$$;\\n\\ngrant execute on function analytics.pulse_location_stats(text, integer)\\n to anon, authenticated, service_role;\\n","004_web_vitals.sql":"-- 004_web_vitals.sql\\n-- Partial index + RPC for Web Vitals p75 aggregation\\n\\n-- Partial index: only covers vitals events, stays small\\nCREATE INDEX IF NOT EXISTS idx_pulse_events_vitals\\n ON analytics.pulse_events (site_id, created_at)\\n WHERE event_type = 'vitals';\\n\\n-- RPC: returns per-metric p75 for each page + site-wide (__overall__)\\nCREATE OR REPLACE FUNCTION analytics.pulse_vitals_stats(\\n p_site_id TEXT,\\n p_days_back INT DEFAULT 7\\n)\\nRETURNS TABLE (\\n path TEXT,\\n metric TEXT,\\n p75 DOUBLE PRECISION,\\n sample_count BIGINT\\n)\\nLANGUAGE sql SECURITY DEFINER STABLE\\nAS $$\\n WITH vitals_raw AS (\\n SELECT\\n e.path,\\n kv.key AS metric,\\n kv.value::double precision AS val\\n FROM analytics.pulse_events e,\\n LATERAL jsonb_each_text(e.meta) AS kv(key, value)\\n WHERE e.site_id = p_site_id\\n AND e.event_type = 'vitals'\\n AND e.created_at >= NOW() - (p_days_back || ' days')::interval\\n AND kv.key IN ('lcp', 'inp', 'cls', 'fcp', 'ttfb')\\n )\\n -- Per-page stats\\n SELECT\\n vr.path,\\n vr.metric,\\n percentile_cont(0.75) WITHIN GROUP (ORDER BY vr.val) AS p75,\\n count(*)::bigint AS sample_count\\n FROM vitals_raw vr\\n GROUP BY vr.path, vr.metric\\n\\n UNION ALL\\n\\n -- Site-wide stats\\n SELECT\\n '__overall__'::text AS path,\\n vr.metric,\\n percentile_cont(0.75) WITHIN GROUP (ORDER BY vr.val) AS p75,\\n count(*)::bigint AS sample_count\\n FROM vitals_raw vr\\n GROUP BY vr.metric;\\n$$;\\n\\nGRANT EXECUTE ON FUNCTION analytics.pulse_vitals_stats(TEXT, INT)\\n TO anon, authenticated, service_role;\\n","005_error_tracking.sql":"-- 005_error_tracking.sql\\n-- Fix existing RPCs to filter by event_type = 'pageview', add error tracking\\n\\n-- \u2500\u2500 Fix pulse_refresh_aggregates: only aggregate pageview events \u2500\u2500\u2500\u2500\u2500\u2500\\nCREATE OR REPLACE FUNCTION analytics.pulse_refresh_aggregates(days_back integer default 7)\\nRETURNS void\\nLANGUAGE sql\\nSECURITY DEFINER\\nAS $$\\n INSERT INTO analytics.pulse_aggregates (date, site_id, path, total_views, unique_visitors)\\n SELECT\\n date_trunc('day', created_at)::date AS date,\\n site_id,\\n path,\\n count(*) AS total_views,\\n count(distinct session_id) AS unique_visitors\\n FROM analytics.pulse_events\\n WHERE created_at >= now() - (days_back || ' days')::interval\\n AND event_type = 'pageview'\\n GROUP BY 1, 2, 3\\n ON CONFLICT (date, site_id, path) DO UPDATE\\n SET\\n total_views = excluded.total_views,\\n unique_visitors = excluded.unique_visitors;\\n$$;\\n\\n-- \u2500\u2500 Fix pulse_stats_by_timezone: only count pageview events \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\\nCREATE OR REPLACE FUNCTION analytics.pulse_stats_by_timezone(\\n p_site_id text,\\n p_timezone text default 'UTC',\\n p_days_back integer default 7\\n)\\nRETURNS TABLE (\\n date date,\\n path text,\\n total_views bigint,\\n unique_visitors bigint\\n)\\nLANGUAGE sql\\nSECURITY DEFINER\\nSTABLE\\nAS $$\\n SELECT\\n date_trunc('day', created_at AT TIME ZONE p_timezone)::date AS date,\\n path,\\n count(*) AS total_views,\\n count(distinct session_id) AS unique_visitors\\n FROM analytics.pulse_events\\n WHERE site_id = p_site_id\\n AND created_at >= now() - make_interval(days => p_days_back + 1)\\n AND event_type = 'pageview'\\n GROUP BY 1, 2;\\n$$;\\n\\n-- \u2500\u2500 Fix pulse_location_stats: only count pageview events \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\\nCREATE OR REPLACE FUNCTION analytics.pulse_location_stats(\\n p_site_id text,\\n p_days_back integer default 7\\n)\\nRETURNS TABLE (\\n country text,\\n city text,\\n latitude double precision,\\n longitude double precision,\\n total_views bigint,\\n unique_visitors bigint\\n)\\nLANGUAGE sql\\nSECURITY DEFINER\\nSTABLE\\nAS $$\\n SELECT\\n country,\\n city,\\n avg(latitude) AS latitude,\\n avg(longitude) AS longitude,\\n count(*) AS total_views,\\n count(distinct session_id) AS unique_visitors\\n FROM analytics.pulse_events\\n WHERE site_id = p_site_id\\n AND created_at >= now() - make_interval(days => p_days_back)\\n AND country IS NOT NULL\\n AND event_type = 'pageview'\\n GROUP BY 1, 2\\n ORDER BY total_views DESC;\\n$$;\\n\\n-- \u2500\u2500 Partial index for error events \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\\nCREATE INDEX IF NOT EXISTS idx_pulse_events_errors\\n ON analytics.pulse_events (site_id, created_at)\\n WHERE event_type IN ('error', 'server_error');\\n\\n-- \u2500\u2500 Error stats RPC \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\\nCREATE OR REPLACE FUNCTION analytics.pulse_error_stats(\\n p_site_id TEXT,\\n p_days_back INT DEFAULT 7\\n)\\nRETURNS TABLE (\\n error_type TEXT,\\n message TEXT,\\n path TEXT,\\n total_count BIGINT,\\n session_count BIGINT,\\n last_seen TIMESTAMPTZ,\\n first_seen TIMESTAMPTZ,\\n sample_meta JSONB\\n)\\nLANGUAGE sql SECURITY DEFINER STABLE\\nAS $$\\n WITH ranked AS (\\n SELECT\\n e.event_type AS error_type,\\n e.meta->>'message' AS message,\\n e.path,\\n count(*) AS total_count,\\n count(DISTINCT e.session_id) AS session_count,\\n max(e.created_at) AS last_seen,\\n min(e.created_at) AS first_seen,\\n -- Get the full meta from the most recent occurrence\\n (ARRAY_AGG(e.meta ORDER BY e.created_at DESC))[1] AS sample_meta\\n FROM analytics.pulse_events e\\n WHERE e.site_id = p_site_id\\n AND e.event_type IN ('error', 'server_error')\\n AND e.created_at >= NOW() - (p_days_back || ' days')::interval\\n GROUP BY e.event_type, e.meta->>'message', e.path\\n )\\n SELECT * FROM ranked\\n ORDER BY last_seen DESC\\n LIMIT 50;\\n$$;\\n\\nGRANT EXECUTE ON FUNCTION analytics.pulse_error_stats(TEXT, INT)\\n TO anon, authenticated, service_role;\\n","006_date_range_support.sql":"-- 006_date_range_support.sql\\n-- Replace p_days_back with p_start_date / p_end_date date range params.\\n-- Both default to NULL \u2192 falls back to last 7 days when not provided.\\n\\n-- \u2500\u2500 pulse_stats_by_timezone \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\\nDROP FUNCTION IF EXISTS analytics.pulse_stats_by_timezone(text, text, integer);\\n\\nCREATE OR REPLACE FUNCTION analytics.pulse_stats_by_timezone(\\n p_site_id text,\\n p_timezone text DEFAULT 'UTC',\\n p_start_date date DEFAULT NULL,\\n p_end_date date DEFAULT NULL\\n)\\nRETURNS TABLE (\\n date date,\\n path text,\\n total_views bigint,\\n unique_visitors bigint\\n)\\nLANGUAGE sql\\nSECURITY DEFINER\\nSTABLE\\nAS $$\\n SELECT\\n date_trunc('day', created_at AT TIME ZONE p_timezone)::date AS date,\\n path,\\n count(*) AS total_views,\\n count(DISTINCT session_id) AS unique_visitors\\n FROM analytics.pulse_events\\n WHERE site_id = p_site_id\\n AND event_type = 'pageview'\\n AND created_at >= (COALESCE(p_start_date, current_date - 7)::timestamp AT TIME ZONE p_timezone)\\n AND created_at < ((COALESCE(p_end_date, current_date) + interval '1 day')::timestamp AT TIME ZONE p_timezone)\\n GROUP BY 1, 2;\\n$$;\\n\\nGRANT EXECUTE ON FUNCTION analytics.pulse_stats_by_timezone(text, text, date, date)\\n TO anon, authenticated, service_role;\\n\\n-- \u2500\u2500 pulse_location_stats \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\\nDROP FUNCTION IF EXISTS analytics.pulse_location_stats(text, integer);\\n\\nCREATE OR REPLACE FUNCTION analytics.pulse_location_stats(\\n p_site_id text,\\n p_start_date date DEFAULT NULL,\\n p_end_date date DEFAULT NULL\\n)\\nRETURNS TABLE (\\n country text,\\n city text,\\n latitude double precision,\\n longitude double precision,\\n total_views bigint,\\n unique_visitors bigint\\n)\\nLANGUAGE sql\\nSECURITY DEFINER\\nSTABLE\\nAS $$\\n SELECT\\n country,\\n city,\\n avg(latitude) AS latitude,\\n avg(longitude) AS longitude,\\n count(*) AS total_views,\\n count(DISTINCT session_id) AS unique_visitors\\n FROM analytics.pulse_events\\n WHERE site_id = p_site_id\\n AND event_type = 'pageview'\\n AND country IS NOT NULL\\n AND created_at >= COALESCE(p_start_date, current_date - 7)::timestamptz\\n AND created_at < (COALESCE(p_end_date, current_date) + interval '1 day')::timestamptz\\n GROUP BY 1, 2\\n ORDER BY total_views DESC;\\n$$;\\n\\nGRANT EXECUTE ON FUNCTION analytics.pulse_location_stats(text, date, date)\\n TO anon, authenticated, service_role;\\n\\n-- \u2500\u2500 pulse_vitals_stats \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\\nDROP FUNCTION IF EXISTS analytics.pulse_vitals_stats(text, int);\\n\\nCREATE OR REPLACE FUNCTION analytics.pulse_vitals_stats(\\n p_site_id text,\\n p_start_date date DEFAULT NULL,\\n p_end_date date DEFAULT NULL\\n)\\nRETURNS TABLE (\\n path text,\\n metric text,\\n p75 double precision,\\n sample_count bigint\\n)\\nLANGUAGE sql SECURITY DEFINER STABLE\\nAS $$\\n WITH vitals_raw AS (\\n SELECT\\n e.path,\\n kv.key AS metric,\\n kv.value::double precision AS val\\n FROM analytics.pulse_events e,\\n LATERAL jsonb_each_text(e.meta) AS kv(key, value)\\n WHERE e.site_id = p_site_id\\n AND e.event_type = 'vitals'\\n AND e.created_at >= COALESCE(p_start_date, current_date - 7)::timestamptz\\n AND e.created_at < (COALESCE(p_end_date, current_date) + interval '1 day')::timestamptz\\n AND kv.key IN ('lcp', 'inp', 'cls', 'fcp', 'ttfb')\\n )\\n -- Per-page stats\\n SELECT\\n vr.path,\\n vr.metric,\\n percentile_cont(0.75) WITHIN GROUP (ORDER BY vr.val) AS p75,\\n count(*)::bigint AS sample_count\\n FROM vitals_raw vr\\n GROUP BY vr.path, vr.metric\\n\\n UNION ALL\\n\\n -- Site-wide stats\\n SELECT\\n '__overall__'::text AS path,\\n vr.metric,\\n percentile_cont(0.75) WITHIN GROUP (ORDER BY vr.val) AS p75,\\n count(*)::bigint AS sample_count\\n FROM vitals_raw vr\\n GROUP BY vr.metric;\\n$$;\\n\\nGRANT EXECUTE ON FUNCTION analytics.pulse_vitals_stats(text, date, date)\\n TO anon, authenticated, service_role;\\n\\n-- \u2500\u2500 pulse_error_stats \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\\nDROP FUNCTION IF EXISTS analytics.pulse_error_stats(text, int);\\n\\nCREATE OR REPLACE FUNCTION analytics.pulse_error_stats(\\n p_site_id text,\\n p_start_date date DEFAULT NULL,\\n p_end_date date DEFAULT NULL\\n)\\nRETURNS TABLE (\\n error_type text,\\n message text,\\n path text,\\n total_count bigint,\\n session_count bigint,\\n last_seen timestamptz,\\n first_seen timestamptz,\\n sample_meta jsonb\\n)\\nLANGUAGE sql SECURITY DEFINER STABLE\\nAS $$\\n WITH ranked AS (\\n SELECT\\n e.event_type AS error_type,\\n e.meta->>'message' AS message,\\n e.path,\\n count(*) AS total_count,\\n count(DISTINCT e.session_id) AS session_count,\\n max(e.created_at) AS last_seen,\\n min(e.created_at) AS first_seen,\\n (ARRAY_AGG(e.meta ORDER BY e.created_at DESC))[1] AS sample_meta\\n FROM analytics.pulse_events e\\n WHERE e.site_id = p_site_id\\n AND e.event_type IN ('error', 'server_error')\\n AND e.created_at >= COALESCE(p_start_date, current_date - 7)::timestamptz\\n AND e.created_at < (COALESCE(p_end_date, current_date) + interval '1 day')::timestamptz\\n GROUP BY e.event_type, e.meta->>'message', e.path\\n )\\n SELECT * FROM ranked\\n ORDER BY last_seen DESC\\n LIMIT 50;\\n$$;\\n\\nGRANT EXECUTE ON FUNCTION analytics.pulse_error_stats(text, date, date)\\n TO anon, authenticated, service_role;\\n","007_data_lifecycle.sql":"-- 007_data_lifecycle.sql\\n-- Automatic data consolidation & cleanup.\\n-- Rolls pageview counts older than retention_days into pulse_aggregates,\\n-- then deletes all old raw events (all event types).\\n\\nCREATE OR REPLACE FUNCTION analytics.pulse_consolidate_and_cleanup(\\n retention_days int DEFAULT 30\\n)\\nRETURNS TABLE (rows_consolidated bigint, rows_deleted bigint)\\nLANGUAGE plpgsql\\nSECURITY DEFINER\\nAS $$\\nDECLARE\\n v_cutoff timestamptz;\\n v_consolidated bigint;\\n v_deleted bigint;\\nBEGIN\\n v_cutoff := now() - make_interval(days => retention_days);\\n\\n -- Step 1: Roll up old pageview events into daily aggregates\\n WITH inserted AS (\\n INSERT INTO analytics.pulse_aggregates (date, site_id, path, total_views, unique_visitors)\\n SELECT\\n date_trunc('day', created_at)::date AS date,\\n site_id,\\n path,\\n count(*)::int AS total_views,\\n count(DISTINCT session_id)::int AS unique_visitors\\n FROM analytics.pulse_events\\n WHERE created_at < v_cutoff\\n AND event_type = 'pageview'\\n GROUP BY 1, 2, 3\\n ON CONFLICT (date, site_id, path) DO UPDATE SET\\n total_views = GREATEST(analytics.pulse_aggregates.total_views, excluded.total_views),\\n unique_visitors = GREATEST(analytics.pulse_aggregates.unique_visitors, excluded.unique_visitors)\\n RETURNING 1\\n )\\n SELECT count(*) INTO v_consolidated FROM inserted;\\n\\n -- Step 2: Delete all old events (pageviews, vitals, errors, etc.)\\n WITH deleted AS (\\n DELETE FROM analytics.pulse_events\\n WHERE created_at < v_cutoff\\n RETURNING 1\\n )\\n SELECT count(*) INTO v_deleted FROM deleted;\\n\\n RETURN QUERY SELECT v_consolidated, v_deleted;\\nEND;\\n$$;\\n\\nGRANT EXECUTE ON FUNCTION analytics.pulse_consolidate_and_cleanup(int)\\n TO anon, authenticated, service_role;\\n\\n-- \u2500\u2500 Replace pulse_stats_by_timezone to union raw events + aggregates \u2500\u2500\\nDROP FUNCTION IF EXISTS analytics.pulse_stats_by_timezone(text, text, date, date);\\n\\nCREATE OR REPLACE FUNCTION analytics.pulse_stats_by_timezone(\\n p_site_id text,\\n p_timezone text DEFAULT 'UTC',\\n p_start_date date DEFAULT NULL,\\n p_end_date date DEFAULT NULL\\n)\\nRETURNS TABLE (\\n date date,\\n path text,\\n total_views bigint,\\n unique_visitors bigint\\n)\\nLANGUAGE sql\\nSECURITY DEFINER\\nSTABLE\\nAS $$\\n WITH\\n -- Find the earliest raw pageview date for this site\\n oldest_raw AS (\\n SELECT min(date_trunc('day', created_at AT TIME ZONE p_timezone)::date) AS min_date\\n FROM analytics.pulse_events\\n WHERE site_id = p_site_id\\n AND event_type = 'pageview'\\n ),\\n -- Aggregated data for dates before the oldest raw event\\n from_aggregates AS (\\n SELECT\\n a.date,\\n a.path,\\n a.total_views::bigint,\\n a.unique_visitors::bigint\\n FROM analytics.pulse_aggregates a, oldest_raw o\\n WHERE a.site_id = p_site_id\\n AND a.date >= COALESCE(p_start_date, current_date - 7)\\n AND a.date < COALESCE(p_end_date, current_date) + 1\\n AND (o.min_date IS NULL OR a.date < o.min_date)\\n ),\\n -- Raw events for recent data\\n from_raw AS (\\n SELECT\\n date_trunc('day', created_at AT TIME ZONE p_timezone)::date AS date,\\n path,\\n count(*) AS total_views,\\n count(DISTINCT session_id) AS unique_visitors\\n FROM analytics.pulse_events\\n WHERE site_id = p_site_id\\n AND event_type = 'pageview'\\n AND created_at >= (COALESCE(p_start_date, current_date - 7)::timestamp AT TIME ZONE p_timezone)\\n AND created_at < ((COALESCE(p_end_date, current_date) + interval '1 day')::timestamp AT TIME ZONE p_timezone)\\n GROUP BY 1, 2\\n )\\n SELECT * FROM from_aggregates\\n UNION ALL\\n SELECT * FROM from_raw;\\n$$;\\n\\nGRANT EXECUTE ON FUNCTION analytics.pulse_stats_by_timezone(text, text, date, date)\\n TO anon, authenticated, service_role;\\n","008_security_hardening.sql":"-- 008_security_hardening.sql\\n-- Tighten grants and RLS policies for production security.\\n-- Replaces the overly broad GRANT ALL from 001_init_pulse.sql with\\n-- minimum-privilege grants per role.\\n\\n-- \u2500\u2500 1. Revoke overly broad table/sequence grants \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\\n\\nREVOKE ALL ON ALL TABLES IN SCHEMA analytics FROM anon;\\nREVOKE ALL ON ALL SEQUENCES IN SCHEMA analytics FROM anon;\\nREVOKE ALL ON ALL TABLES IN SCHEMA analytics FROM authenticated;\\n\\n-- \u2500\u2500 2. Grant minimum privileges per role \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\\n\\n-- anon: INSERT only on pulse_events (used by the ingestion API route)\\nGRANT INSERT ON analytics.pulse_events TO anon;\\nGRANT USAGE ON SEQUENCE analytics.pulse_events_id_seq TO anon;\\n\\n-- authenticated: read-only on all analytics tables\\nGRANT SELECT ON ALL TABLES IN SCHEMA analytics TO authenticated;\\n\\n-- service_role: full access (admin operations, consolidation, etc.)\\nGRANT ALL ON ALL TABLES IN SCHEMA analytics TO service_role;\\nGRANT ALL ON ALL SEQUENCES IN SCHEMA analytics TO service_role;\\n\\n-- \u2500\u2500 3. Restrict anon insert to valid event types only \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\\n\\nDROP POLICY IF EXISTS \\"Allow anon insert on pulse_events\\" ON analytics.pulse_events;\\nCREATE POLICY \\"Allow anon insert on pulse_events\\"\\n ON analytics.pulse_events\\n FOR INSERT\\n TO anon\\n WITH CHECK (\\n event_type IN ('pageview', 'custom', 'vitals', 'error', 'server_error')\\n );\\n\\n-- \u2500\u2500 4. Remove anon read access on aggregates (not needed publicly) \u2500\u2500\u2500\\n\\nDROP POLICY IF EXISTS \\"Allow anon select on pulse_aggregates\\" ON analytics.pulse_aggregates;\\n\\n-- \u2500\u2500 5. Revoke RPC execute from anon \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\\n-- Read RPCs should only be callable by authenticated/service_role.\\n-- The admin dashboard must use the service_role key (server-side only).\\n\\nREVOKE EXECUTE ON FUNCTION analytics.pulse_stats_by_timezone(text, text, date, date) FROM anon;\\nREVOKE EXECUTE ON FUNCTION analytics.pulse_location_stats(text, date, date) FROM anon;\\nREVOKE EXECUTE ON FUNCTION analytics.pulse_vitals_stats(text, date, date) FROM anon;\\nREVOKE EXECUTE ON FUNCTION analytics.pulse_error_stats(text, date, date) FROM anon;\\n\\n-- \u2500\u2500 6. Consolidate/cleanup is admin-only (service_role via cron) \u2500\u2500\u2500\u2500\u2500\\n\\nREVOKE EXECUTE ON FUNCTION analytics.pulse_consolidate_and_cleanup(int) FROM anon, authenticated;\\n\\n-- \u2500\u2500 7. Fix default privileges for future tables \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\\n\\nALTER DEFAULT PRIVILEGES IN SCHEMA analytics REVOKE ALL ON TABLES FROM anon, authenticated;\\nALTER DEFAULT PRIVILEGES IN SCHEMA analytics GRANT SELECT ON TABLES TO authenticated;\\nALTER DEFAULT PRIVILEGES IN SCHEMA analytics GRANT ALL ON TABLES TO service_role;\\n\\nNOTIFY pgrst, 'reload config';\\nNOTIFY pgrst, 'reload schema';\\n","009_referrer_tracking.sql":"-- Add referrer column\\nALTER TABLE analytics.pulse_events\\n ADD COLUMN IF NOT EXISTS referrer text;\\n\\n-- Index for referrer aggregation queries\\nCREATE INDEX IF NOT EXISTS idx_pulse_events_referrer\\n ON analytics.pulse_events (site_id, created_at)\\n WHERE event_type = 'pageview' AND referrer IS NOT NULL;\\n\\n-- RPC: aggregate pageviews by referrer hostname\\nCREATE OR REPLACE FUNCTION analytics.pulse_referrer_stats(\\n p_site_id text,\\n p_start_date date DEFAULT NULL,\\n p_end_date date DEFAULT NULL\\n)\\nRETURNS TABLE (\\n referrer text,\\n total_views bigint,\\n unique_visitors bigint\\n)\\nLANGUAGE sql SECURITY DEFINER STABLE\\nAS $$\\n SELECT\\n COALESCE(NULLIF(referrer, ''), '(direct)') AS referrer,\\n count(*) AS total_views,\\n count(DISTINCT session_id) AS unique_visitors\\n FROM analytics.pulse_events\\n WHERE site_id = p_site_id\\n AND event_type = 'pageview'\\n AND created_at >= COALESCE(p_start_date, current_date - 7)::timestamptz\\n AND created_at < (COALESCE(p_end_date, current_date) + interval '1 day')::timestamptz\\n GROUP BY 1\\n ORDER BY total_views DESC\\n LIMIT 20;\\n$$;\\n\\nGRANT EXECUTE ON FUNCTION analytics.pulse_referrer_stats(text, date, date)\\n TO anon, authenticated, service_role;\\n"}`
|
|
481
|
+
`{"001_init_pulse.sql":"create schema if not exists analytics;\\n\\n-- Add analytics to the schemas exposed by PostgREST\\nalter role authenticator set pgrst.db_schemas = 'public, graphql_public, analytics';\\n\\n-- Schema-level access\\ngrant usage on schema analytics to anon, authenticated, service_role;\\nalter default privileges in schema analytics grant select on tables to authenticated;\\nalter default privileges in schema analytics grant all on tables to service_role;\\n\\ncreate table if not exists analytics.pulse_events (\\n id bigserial primary key,\\n site_id text not null,\\n session_id text,\\n path text not null,\\n event_type text not null,\\n meta jsonb,\\n created_at timestamptz not null default now()\\n);\\n\\ncreate index if not exists idx_pulse_events_site_created_at\\n on analytics.pulse_events (site_id, created_at);\\n\\ncreate index if not exists idx_pulse_events_site_path_created_at\\n on analytics.pulse_events (site_id, path, created_at);\\n\\nalter table analytics.pulse_events enable row level security; \\n \\n-- Allow the anon key (API route) to insert events\\ndrop policy if exists \\"Allow anon insert on pulse_events\\" on analytics.pulse_events;\\ncreate policy \\"Allow anon insert on pulse_events\\"\\n on analytics.pulse_events\\n for insert\\n to anon\\n with check (true);\\n\\n-- Only authenticated users (dashboard) can read events\\ndrop policy if exists \\"Allow authenticated select on pulse_events\\" on analytics.pulse_events;\\ncreate policy \\"Allow authenticated select on pulse_events\\"\\n on analytics.pulse_events\\n for select\\n to authenticated\\n using (true);\\n\\ncreate table if not exists analytics.pulse_aggregates (\\n date date not null,\\n site_id text not null,\\n path text not null,\\n total_views integer not null default 0,\\n unique_visitors integer not null default 0,\\n primary key (date, site_id, path)\\n);\\n\\n-- Grant table-level access (must be after table creation)\\n-- anon: INSERT only on pulse_events (used by the ingestion API route)\\ngrant insert on analytics.pulse_events to anon;\\ngrant usage on sequence analytics.pulse_events_id_seq to anon;\\n\\n-- authenticated: read-only on all analytics tables\\ngrant select on all tables in schema analytics to authenticated;\\n\\n-- service_role: full access (admin operations, consolidation, etc.)\\ngrant all on all tables in schema analytics to service_role;\\ngrant all on all sequences in schema analytics to service_role;\\n\\nalter table analytics.pulse_aggregates enable row level security;\\n\\n-- Allow reading aggregates (dashboard)\\ndrop policy if exists \\"Allow authenticated select on pulse_aggregates\\" on analytics.pulse_aggregates;\\ncreate policy \\"Allow authenticated select on pulse_aggregates\\"\\n on analytics.pulse_aggregates\\n for select\\n to authenticated\\n using (true);\\n\\n-- Reload PostgREST config and schema cache (must be last)\\nnotify pgrst, 'reload config';\\nnotify pgrst, 'reload schema';\\n","002_aggregation_function.sql":"-- Aggregation function: rolls up raw events into daily aggregates\\ncreate or replace function analytics.pulse_refresh_aggregates(days_back integer default 7)\\nreturns void\\nlanguage sql\\nsecurity definer\\nset search_path = analytics\\nas $$\\n insert into analytics.pulse_aggregates (date, site_id, path, total_views, unique_visitors)\\n select\\n date_trunc('day', created_at)::date as date,\\n site_id,\\n path,\\n count(*) as total_views,\\n count(distinct session_id) as unique_visitors\\n from analytics.pulse_events\\n where created_at >= now() - (days_back || ' days')::interval\\n group by 1, 2, 3\\n on conflict (date, site_id, path) do update\\n set\\n total_views = excluded.total_views,\\n unique_visitors = excluded.unique_visitors;\\n$$;\\n\\n-- Allow all roles to execute the aggregation function\\n-- security definer ensures it runs with the owner's privileges regardless of caller\\ngrant execute on function analytics.pulse_refresh_aggregates(integer) to anon, authenticated, service_role;\\n","003_geo_and_timezone.sql":"-- Add geo columns to pulse_events\\nalter table analytics.pulse_events\\n add column if not exists country text,\\n add column if not exists region text,\\n add column if not exists city text,\\n add column if not exists timezone text,\\n add column if not exists latitude double precision,\\n add column if not exists longitude double precision;\\n\\n-- Timezone-aware stats: queries raw events with AT TIME ZONE\\n-- so the dashboard can display data bucketed by the viewer's local day.\\ncreate or replace function analytics.pulse_stats_by_timezone(\\n p_site_id text,\\n p_timezone text default 'UTC',\\n p_days_back integer default 7\\n)\\nreturns table (\\n date date,\\n path text,\\n total_views bigint,\\n unique_visitors bigint\\n)\\nlanguage sql\\nsecurity definer\\nstable\\nset search_path = analytics\\nas $$\\n select\\n date_trunc('day', created_at at time zone p_timezone)::date as date,\\n path,\\n count(*) as total_views,\\n count(distinct session_id) as unique_visitors\\n from analytics.pulse_events\\n where site_id = p_site_id\\n and created_at >= now() - make_interval(days => p_days_back + 1)\\n group by 1, 2;\\n$$;\\n\\ngrant execute on function analytics.pulse_stats_by_timezone(text, text, integer)\\n to anon, authenticated, service_role;\\n\\n-- Drop first so return type can change (CREATE OR REPLACE cannot alter return columns)\\ndrop function if exists analytics.pulse_location_stats(text, integer);\\n\\n-- Location stats: visitor counts grouped by country + city, with averaged coordinates\\ncreate or replace function analytics.pulse_location_stats(\\n p_site_id text,\\n p_days_back integer default 7\\n)\\nreturns table (\\n country text,\\n city text,\\n latitude double precision,\\n longitude double precision,\\n total_views bigint,\\n unique_visitors bigint\\n)\\nlanguage sql\\nsecurity definer\\nstable\\nset search_path = analytics\\nas $$\\n select\\n country,\\n city,\\n avg(latitude) as latitude,\\n avg(longitude) as longitude,\\n count(*) as total_views,\\n count(distinct session_id) as unique_visitors\\n from analytics.pulse_events\\n where site_id = p_site_id\\n and created_at >= now() - make_interval(days => p_days_back)\\n and country is not null\\n group by 1, 2\\n order by total_views desc;\\n$$;\\n\\ngrant execute on function analytics.pulse_location_stats(text, integer)\\n to anon, authenticated, service_role;\\n","004_web_vitals.sql":"-- 004_web_vitals.sql\\n-- Partial index + RPC for Web Vitals p75 aggregation\\n\\n-- Partial index: only covers vitals events, stays small\\nCREATE INDEX IF NOT EXISTS idx_pulse_events_vitals\\n ON analytics.pulse_events (site_id, created_at)\\n WHERE event_type = 'vitals';\\n\\n-- RPC: returns per-metric p75 for each page + site-wide (__overall__)\\nCREATE OR REPLACE FUNCTION analytics.pulse_vitals_stats(\\n p_site_id TEXT,\\n p_days_back INT DEFAULT 7\\n)\\nRETURNS TABLE (\\n path TEXT,\\n metric TEXT,\\n p75 DOUBLE PRECISION,\\n sample_count BIGINT\\n)\\nLANGUAGE sql SECURITY DEFINER STABLE\\nSET search_path = analytics\\nAS $$\\n WITH vitals_raw AS (\\n SELECT\\n e.path,\\n kv.key AS metric,\\n kv.value::double precision AS val\\n FROM analytics.pulse_events e,\\n LATERAL jsonb_each_text(e.meta) AS kv(key, value)\\n WHERE e.site_id = p_site_id\\n AND e.event_type = 'vitals'\\n AND e.created_at >= NOW() - (p_days_back || ' days')::interval\\n AND kv.key IN ('lcp', 'inp', 'cls', 'fcp', 'ttfb')\\n )\\n -- Per-page stats\\n SELECT\\n vr.path,\\n vr.metric,\\n percentile_cont(0.75) WITHIN GROUP (ORDER BY vr.val) AS p75,\\n count(*)::bigint AS sample_count\\n FROM vitals_raw vr\\n GROUP BY vr.path, vr.metric\\n\\n UNION ALL\\n\\n -- Site-wide stats\\n SELECT\\n '__overall__'::text AS path,\\n vr.metric,\\n percentile_cont(0.75) WITHIN GROUP (ORDER BY vr.val) AS p75,\\n count(*)::bigint AS sample_count\\n FROM vitals_raw vr\\n GROUP BY vr.metric;\\n$$;\\n\\nGRANT EXECUTE ON FUNCTION analytics.pulse_vitals_stats(TEXT, INT)\\n TO anon, authenticated, service_role;\\n","005_error_tracking.sql":"-- 005_error_tracking.sql\\n-- Fix existing RPCs to filter by event_type = 'pageview', add error tracking\\n\\n-- \u2500\u2500 Fix pulse_refresh_aggregates: only aggregate pageview events \u2500\u2500\u2500\u2500\u2500\u2500\\nCREATE OR REPLACE FUNCTION analytics.pulse_refresh_aggregates(days_back integer default 7)\\nRETURNS void\\nLANGUAGE sql\\nSECURITY DEFINER\\nSET search_path = analytics\\nAS $$\\n INSERT INTO analytics.pulse_aggregates (date, site_id, path, total_views, unique_visitors)\\n SELECT\\n date_trunc('day', created_at)::date AS date,\\n site_id,\\n path,\\n count(*) AS total_views,\\n count(distinct session_id) AS unique_visitors\\n FROM analytics.pulse_events\\n WHERE created_at >= now() - (days_back || ' days')::interval\\n AND event_type = 'pageview'\\n GROUP BY 1, 2, 3\\n ON CONFLICT (date, site_id, path) DO UPDATE\\n SET\\n total_views = excluded.total_views,\\n unique_visitors = excluded.unique_visitors;\\n$$;\\n\\n-- \u2500\u2500 Fix pulse_stats_by_timezone: only count pageview events \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\\nCREATE OR REPLACE FUNCTION analytics.pulse_stats_by_timezone(\\n p_site_id text,\\n p_timezone text default 'UTC',\\n p_days_back integer default 7\\n)\\nRETURNS TABLE (\\n date date,\\n path text,\\n total_views bigint,\\n unique_visitors bigint\\n)\\nLANGUAGE sql\\nSECURITY DEFINER\\nSTABLE\\nSET search_path = analytics\\nAS $$\\n SELECT\\n date_trunc('day', created_at AT TIME ZONE p_timezone)::date AS date,\\n path,\\n count(*) AS total_views,\\n count(distinct session_id) AS unique_visitors\\n FROM analytics.pulse_events\\n WHERE site_id = p_site_id\\n AND created_at >= now() - make_interval(days => p_days_back + 1)\\n AND event_type = 'pageview'\\n GROUP BY 1, 2;\\n$$;\\n\\n-- \u2500\u2500 Fix pulse_location_stats: only count pageview events \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\\nCREATE OR REPLACE FUNCTION analytics.pulse_location_stats(\\n p_site_id text,\\n p_days_back integer default 7\\n)\\nRETURNS TABLE (\\n country text,\\n city text,\\n latitude double precision,\\n longitude double precision,\\n total_views bigint,\\n unique_visitors bigint\\n)\\nLANGUAGE sql\\nSECURITY DEFINER\\nSTABLE\\nSET search_path = analytics\\nAS $$\\n SELECT\\n country,\\n city,\\n avg(latitude) AS latitude,\\n avg(longitude) AS longitude,\\n count(*) AS total_views,\\n count(distinct session_id) AS unique_visitors\\n FROM analytics.pulse_events\\n WHERE site_id = p_site_id\\n AND created_at >= now() - make_interval(days => p_days_back)\\n AND country IS NOT NULL\\n AND event_type = 'pageview'\\n GROUP BY 1, 2\\n ORDER BY total_views DESC;\\n$$;\\n\\n-- \u2500\u2500 Partial index for error events \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\\nCREATE INDEX IF NOT EXISTS idx_pulse_events_errors\\n ON analytics.pulse_events (site_id, created_at)\\n WHERE event_type IN ('error', 'server_error');\\n\\n-- \u2500\u2500 Error stats RPC \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\\nCREATE OR REPLACE FUNCTION analytics.pulse_error_stats(\\n p_site_id TEXT,\\n p_days_back INT DEFAULT 7\\n)\\nRETURNS TABLE (\\n error_type TEXT,\\n message TEXT,\\n path TEXT,\\n total_count BIGINT,\\n session_count BIGINT,\\n last_seen TIMESTAMPTZ,\\n first_seen TIMESTAMPTZ,\\n sample_meta JSONB\\n)\\nLANGUAGE sql SECURITY DEFINER STABLE\\nSET search_path = analytics\\nAS $$\\n WITH ranked AS (\\n SELECT\\n e.event_type AS error_type,\\n e.meta->>'message' AS message,\\n e.path,\\n count(*) AS total_count,\\n count(DISTINCT e.session_id) AS session_count,\\n max(e.created_at) AS last_seen,\\n min(e.created_at) AS first_seen,\\n -- Get the full meta from the most recent occurrence\\n (ARRAY_AGG(e.meta ORDER BY e.created_at DESC))[1] AS sample_meta\\n FROM analytics.pulse_events e\\n WHERE e.site_id = p_site_id\\n AND e.event_type IN ('error', 'server_error')\\n AND e.created_at >= NOW() - (p_days_back || ' days')::interval\\n GROUP BY e.event_type, e.meta->>'message', e.path\\n )\\n SELECT * FROM ranked\\n ORDER BY last_seen DESC\\n LIMIT 50;\\n$$;\\n\\nGRANT EXECUTE ON FUNCTION analytics.pulse_error_stats(TEXT, INT)\\n TO anon, authenticated, service_role;\\n","006_date_range_support.sql":"-- 006_date_range_support.sql\\n-- Replace p_days_back with p_start_date / p_end_date date range params.\\n-- Both default to NULL \u2192 falls back to last 7 days when not provided.\\n\\n-- \u2500\u2500 pulse_stats_by_timezone \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\\nDROP FUNCTION IF EXISTS analytics.pulse_stats_by_timezone(text, text, integer);\\n\\nCREATE OR REPLACE FUNCTION analytics.pulse_stats_by_timezone(\\n p_site_id text,\\n p_timezone text DEFAULT 'UTC',\\n p_start_date date DEFAULT NULL,\\n p_end_date date DEFAULT NULL\\n)\\nRETURNS TABLE (\\n date date,\\n path text,\\n total_views bigint,\\n unique_visitors bigint\\n)\\nLANGUAGE sql\\nSECURITY DEFINER\\nSTABLE\\nSET search_path = analytics\\nAS $$\\n SELECT\\n date_trunc('day', created_at AT TIME ZONE p_timezone)::date AS date,\\n path,\\n count(*) AS total_views,\\n count(DISTINCT session_id) AS unique_visitors\\n FROM analytics.pulse_events\\n WHERE site_id = p_site_id\\n AND event_type = 'pageview'\\n AND created_at >= (COALESCE(p_start_date, current_date - 7)::timestamp AT TIME ZONE p_timezone)\\n AND created_at < ((COALESCE(p_end_date, current_date) + interval '1 day')::timestamp AT TIME ZONE p_timezone)\\n GROUP BY 1, 2;\\n$$;\\n\\nGRANT EXECUTE ON FUNCTION analytics.pulse_stats_by_timezone(text, text, date, date)\\n TO anon, authenticated, service_role;\\n\\n-- \u2500\u2500 pulse_location_stats \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\\nDROP FUNCTION IF EXISTS analytics.pulse_location_stats(text, integer);\\n\\nCREATE OR REPLACE FUNCTION analytics.pulse_location_stats(\\n p_site_id text,\\n p_start_date date DEFAULT NULL,\\n p_end_date date DEFAULT NULL\\n)\\nRETURNS TABLE (\\n country text,\\n city text,\\n latitude double precision,\\n longitude double precision,\\n total_views bigint,\\n unique_visitors bigint\\n)\\nLANGUAGE sql\\nSECURITY DEFINER\\nSTABLE\\nSET search_path = analytics\\nAS $$\\n SELECT\\n country,\\n city,\\n avg(latitude) AS latitude,\\n avg(longitude) AS longitude,\\n count(*) AS total_views,\\n count(DISTINCT session_id) AS unique_visitors\\n FROM analytics.pulse_events\\n WHERE site_id = p_site_id\\n AND event_type = 'pageview'\\n AND country IS NOT NULL\\n AND created_at >= COALESCE(p_start_date, current_date - 7)::timestamptz\\n AND created_at < (COALESCE(p_end_date, current_date) + interval '1 day')::timestamptz\\n GROUP BY 1, 2\\n ORDER BY total_views DESC;\\n$$;\\n\\nGRANT EXECUTE ON FUNCTION analytics.pulse_location_stats(text, date, date)\\n TO anon, authenticated, service_role;\\n\\n-- \u2500\u2500 pulse_vitals_stats \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\\nDROP FUNCTION IF EXISTS analytics.pulse_vitals_stats(text, int);\\n\\nCREATE OR REPLACE FUNCTION analytics.pulse_vitals_stats(\\n p_site_id text,\\n p_start_date date DEFAULT NULL,\\n p_end_date date DEFAULT NULL\\n)\\nRETURNS TABLE (\\n path text,\\n metric text,\\n p75 double precision,\\n sample_count bigint\\n)\\nLANGUAGE sql SECURITY DEFINER STABLE\\nSET search_path = analytics\\nAS $$\\n WITH vitals_raw AS (\\n SELECT\\n e.path,\\n kv.key AS metric,\\n kv.value::double precision AS val\\n FROM analytics.pulse_events e,\\n LATERAL jsonb_each_text(e.meta) AS kv(key, value)\\n WHERE e.site_id = p_site_id\\n AND e.event_type = 'vitals'\\n AND e.created_at >= COALESCE(p_start_date, current_date - 7)::timestamptz\\n AND e.created_at < (COALESCE(p_end_date, current_date) + interval '1 day')::timestamptz\\n AND kv.key IN ('lcp', 'inp', 'cls', 'fcp', 'ttfb')\\n )\\n -- Per-page stats\\n SELECT\\n vr.path,\\n vr.metric,\\n percentile_cont(0.75) WITHIN GROUP (ORDER BY vr.val) AS p75,\\n count(*)::bigint AS sample_count\\n FROM vitals_raw vr\\n GROUP BY vr.path, vr.metric\\n\\n UNION ALL\\n\\n -- Site-wide stats\\n SELECT\\n '__overall__'::text AS path,\\n vr.metric,\\n percentile_cont(0.75) WITHIN GROUP (ORDER BY vr.val) AS p75,\\n count(*)::bigint AS sample_count\\n FROM vitals_raw vr\\n GROUP BY vr.metric;\\n$$;\\n\\nGRANT EXECUTE ON FUNCTION analytics.pulse_vitals_stats(text, date, date)\\n TO anon, authenticated, service_role;\\n\\n-- \u2500\u2500 pulse_error_stats \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\\nDROP FUNCTION IF EXISTS analytics.pulse_error_stats(text, int);\\n\\nCREATE OR REPLACE FUNCTION analytics.pulse_error_stats(\\n p_site_id text,\\n p_start_date date DEFAULT NULL,\\n p_end_date date DEFAULT NULL\\n)\\nRETURNS TABLE (\\n error_type text,\\n message text,\\n path text,\\n total_count bigint,\\n session_count bigint,\\n last_seen timestamptz,\\n first_seen timestamptz,\\n sample_meta jsonb\\n)\\nLANGUAGE sql SECURITY DEFINER STABLE\\nSET search_path = analytics\\nAS $$\\n WITH ranked AS (\\n SELECT\\n e.event_type AS error_type,\\n e.meta->>'message' AS message,\\n e.path,\\n count(*) AS total_count,\\n count(DISTINCT e.session_id) AS session_count,\\n max(e.created_at) AS last_seen,\\n min(e.created_at) AS first_seen,\\n (ARRAY_AGG(e.meta ORDER BY e.created_at DESC))[1] AS sample_meta\\n FROM analytics.pulse_events e\\n WHERE e.site_id = p_site_id\\n AND e.event_type IN ('error', 'server_error')\\n AND e.created_at >= COALESCE(p_start_date, current_date - 7)::timestamptz\\n AND e.created_at < (COALESCE(p_end_date, current_date) + interval '1 day')::timestamptz\\n GROUP BY e.event_type, e.meta->>'message', e.path\\n )\\n SELECT * FROM ranked\\n ORDER BY last_seen DESC\\n LIMIT 50;\\n$$;\\n\\nGRANT EXECUTE ON FUNCTION analytics.pulse_error_stats(text, date, date)\\n TO anon, authenticated, service_role;\\n","007_data_lifecycle.sql":"-- 007_data_lifecycle.sql\\n-- Automatic data consolidation & cleanup.\\n-- Rolls pageview counts older than retention_days into pulse_aggregates,\\n-- then deletes all old raw events (all event types).\\n\\nCREATE OR REPLACE FUNCTION analytics.pulse_consolidate_and_cleanup(\\n retention_days int DEFAULT 30\\n)\\nRETURNS TABLE (rows_consolidated bigint, rows_deleted bigint)\\nLANGUAGE plpgsql\\nSECURITY DEFINER\\nSET search_path = analytics\\nAS $$\\nDECLARE\\n v_cutoff timestamptz;\\n v_consolidated bigint;\\n v_deleted bigint;\\nBEGIN\\n v_cutoff := now() - make_interval(days => retention_days);\\n\\n -- Step 1: Roll up old pageview events into daily aggregates\\n WITH inserted AS (\\n INSERT INTO analytics.pulse_aggregates (date, site_id, path, total_views, unique_visitors)\\n SELECT\\n date_trunc('day', created_at)::date AS date,\\n site_id,\\n path,\\n count(*)::int AS total_views,\\n count(DISTINCT session_id)::int AS unique_visitors\\n FROM analytics.pulse_events\\n WHERE created_at < v_cutoff\\n AND event_type = 'pageview'\\n GROUP BY 1, 2, 3\\n ON CONFLICT (date, site_id, path) DO UPDATE SET\\n total_views = GREATEST(analytics.pulse_aggregates.total_views, excluded.total_views),\\n unique_visitors = GREATEST(analytics.pulse_aggregates.unique_visitors, excluded.unique_visitors)\\n RETURNING 1\\n )\\n SELECT count(*) INTO v_consolidated FROM inserted;\\n\\n -- Step 2: Delete all old events (pageviews, vitals, errors, etc.)\\n WITH deleted AS (\\n DELETE FROM analytics.pulse_events\\n WHERE created_at < v_cutoff\\n RETURNING 1\\n )\\n SELECT count(*) INTO v_deleted FROM deleted;\\n\\n RETURN QUERY SELECT v_consolidated, v_deleted;\\nEND;\\n$$;\\n\\nGRANT EXECUTE ON FUNCTION analytics.pulse_consolidate_and_cleanup(int)\\n TO anon, authenticated, service_role;\\n\\n-- \u2500\u2500 Replace pulse_stats_by_timezone to union raw events + aggregates \u2500\u2500\\nDROP FUNCTION IF EXISTS analytics.pulse_stats_by_timezone(text, text, date, date);\\n\\nCREATE OR REPLACE FUNCTION analytics.pulse_stats_by_timezone(\\n p_site_id text,\\n p_timezone text DEFAULT 'UTC',\\n p_start_date date DEFAULT NULL,\\n p_end_date date DEFAULT NULL\\n)\\nRETURNS TABLE (\\n date date,\\n path text,\\n total_views bigint,\\n unique_visitors bigint\\n)\\nLANGUAGE sql\\nSECURITY DEFINER\\nSTABLE\\nSET search_path = analytics\\nAS $$\\n WITH\\n -- Find the earliest raw pageview date for this site\\n oldest_raw AS (\\n SELECT min(date_trunc('day', created_at AT TIME ZONE p_timezone)::date) AS min_date\\n FROM analytics.pulse_events\\n WHERE site_id = p_site_id\\n AND event_type = 'pageview'\\n ),\\n -- Aggregated data for dates before the oldest raw event\\n from_aggregates AS (\\n SELECT\\n a.date,\\n a.path,\\n a.total_views::bigint,\\n a.unique_visitors::bigint\\n FROM analytics.pulse_aggregates a, oldest_raw o\\n WHERE a.site_id = p_site_id\\n AND a.date >= COALESCE(p_start_date, current_date - 7)\\n AND a.date < COALESCE(p_end_date, current_date) + 1\\n AND (o.min_date IS NULL OR a.date < o.min_date)\\n ),\\n -- Raw events for recent data\\n from_raw AS (\\n SELECT\\n date_trunc('day', created_at AT TIME ZONE p_timezone)::date AS date,\\n path,\\n count(*) AS total_views,\\n count(DISTINCT session_id) AS unique_visitors\\n FROM analytics.pulse_events\\n WHERE site_id = p_site_id\\n AND event_type = 'pageview'\\n AND created_at >= (COALESCE(p_start_date, current_date - 7)::timestamp AT TIME ZONE p_timezone)\\n AND created_at < ((COALESCE(p_end_date, current_date) + interval '1 day')::timestamp AT TIME ZONE p_timezone)\\n GROUP BY 1, 2\\n )\\n SELECT * FROM from_aggregates\\n UNION ALL\\n SELECT * FROM from_raw;\\n$$;\\n\\nGRANT EXECUTE ON FUNCTION analytics.pulse_stats_by_timezone(text, text, date, date)\\n TO anon, authenticated, service_role;\\n","008_security_hardening.sql":"-- 008_security_hardening.sql\\n-- Tighten grants and RLS policies for production security.\\n-- Replaces the overly broad GRANT ALL from 001_init_pulse.sql with\\n-- minimum-privilege grants per role.\\n\\n-- \u2500\u2500 1. Revoke overly broad table/sequence grants \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\\n\\nREVOKE ALL ON ALL TABLES IN SCHEMA analytics FROM anon;\\nREVOKE ALL ON ALL SEQUENCES IN SCHEMA analytics FROM anon;\\nREVOKE ALL ON ALL TABLES IN SCHEMA analytics FROM authenticated;\\n\\n-- \u2500\u2500 2. Grant minimum privileges per role \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\\n\\n-- anon: INSERT only on pulse_events (used by the ingestion API route)\\nGRANT INSERT ON analytics.pulse_events TO anon;\\nGRANT USAGE ON SEQUENCE analytics.pulse_events_id_seq TO anon;\\n\\n-- authenticated: read-only on all analytics tables\\nGRANT SELECT ON ALL TABLES IN SCHEMA analytics TO authenticated;\\n\\n-- service_role: full access (admin operations, consolidation, etc.)\\nGRANT ALL ON ALL TABLES IN SCHEMA analytics TO service_role;\\nGRANT ALL ON ALL SEQUENCES IN SCHEMA analytics TO service_role;\\n\\n-- \u2500\u2500 3. Restrict anon insert to valid event types only \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\\n\\nDROP POLICY IF EXISTS \\"Allow anon insert on pulse_events\\" ON analytics.pulse_events;\\nCREATE POLICY \\"Allow anon insert on pulse_events\\"\\n ON analytics.pulse_events\\n FOR INSERT\\n TO anon\\n WITH CHECK (\\n event_type IN ('pageview', 'custom', 'vitals', 'error', 'server_error')\\n );\\n\\n-- \u2500\u2500 4. Remove anon read access on aggregates (not needed publicly) \u2500\u2500\u2500\\n\\nDROP POLICY IF EXISTS \\"Allow anon select on pulse_aggregates\\" ON analytics.pulse_aggregates;\\n\\n-- \u2500\u2500 5. Revoke RPC execute from anon \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\\n-- Read RPCs should only be callable by authenticated/service_role.\\n-- The admin dashboard must use the service_role key (server-side only).\\n\\nREVOKE EXECUTE ON FUNCTION analytics.pulse_stats_by_timezone(text, text, date, date) FROM anon;\\nREVOKE EXECUTE ON FUNCTION analytics.pulse_location_stats(text, date, date) FROM anon;\\nREVOKE EXECUTE ON FUNCTION analytics.pulse_vitals_stats(text, date, date) FROM anon;\\nREVOKE EXECUTE ON FUNCTION analytics.pulse_error_stats(text, date, date) FROM anon;\\n\\n-- \u2500\u2500 6. Consolidate/cleanup is admin-only (service_role via cron) \u2500\u2500\u2500\u2500\u2500\\n\\nREVOKE EXECUTE ON FUNCTION analytics.pulse_consolidate_and_cleanup(int) FROM anon, authenticated;\\n\\n-- \u2500\u2500 7. Fix default privileges for future tables \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\\n\\nALTER DEFAULT PRIVILEGES IN SCHEMA analytics REVOKE ALL ON TABLES FROM anon, authenticated;\\nALTER DEFAULT PRIVILEGES IN SCHEMA analytics GRANT SELECT ON TABLES TO authenticated;\\nALTER DEFAULT PRIVILEGES IN SCHEMA analytics GRANT ALL ON TABLES TO service_role;\\n\\nNOTIFY pgrst, 'reload config';\\nNOTIFY pgrst, 'reload schema';\\n","009_referrer_tracking.sql":"-- Add referrer column\\nALTER TABLE analytics.pulse_events\\n ADD COLUMN IF NOT EXISTS referrer text;\\n\\n-- Index for referrer aggregation queries\\nCREATE INDEX IF NOT EXISTS idx_pulse_events_referrer\\n ON analytics.pulse_events (site_id, created_at)\\n WHERE event_type = 'pageview' AND referrer IS NOT NULL;\\n\\n-- RPC: aggregate pageviews by referrer hostname\\nCREATE OR REPLACE FUNCTION analytics.pulse_referrer_stats(\\n p_site_id text,\\n p_start_date date DEFAULT NULL,\\n p_end_date date DEFAULT NULL\\n)\\nRETURNS TABLE (\\n referrer text,\\n total_views bigint,\\n unique_visitors bigint\\n)\\nLANGUAGE sql SECURITY DEFINER STABLE\\nSET search_path = analytics\\nAS $$\\n SELECT\\n COALESCE(NULLIF(referrer, ''), '(direct)') AS referrer,\\n count(*) AS total_views,\\n count(DISTINCT session_id) AS unique_visitors\\n FROM analytics.pulse_events\\n WHERE site_id = p_site_id\\n AND event_type = 'pageview'\\n AND created_at >= COALESCE(p_start_date, current_date - 7)::timestamptz\\n AND created_at < (COALESCE(p_end_date, current_date) + interval '1 day')::timestamptz\\n GROUP BY 1\\n ORDER BY total_views DESC\\n LIMIT 20;\\n$$;\\n\\nGRANT EXECUTE ON FUNCTION analytics.pulse_referrer_stats(text, date, date)\\n TO anon, authenticated, service_role;\\n","010_fix_aggregation.sql":"-- 010_fix_aggregation.sql\\n--\\n-- Fixes two bugs:\\n--\\n-- Bug #1 \u2014 pulse_refresh_aggregates used ON CONFLICT DO UPDATE SET col = excluded.col,\\n-- which overwrites existing aggregates instead of keeping the higher value. If a\\n-- partial refresh runs (smaller window, or consolidation deleted some raw events\\n-- before refresh completed), it could silently reduce counts. Fix: use GREATEST().\\n--\\n-- Bug #3 \u2014 pulse_referrer_stats only queries raw pulse_events. Once\\n-- pulse_consolidate_and_cleanup deletes events beyond the retention window, referrer\\n-- data for those dates is permanently lost and the dashboard silently returns\\n-- incomplete results for longer date ranges.\\n-- Fix: add pulse_referrer_aggregates table, archive referrer data during\\n-- consolidation, and rewrite pulse_referrer_stats to union raw + aggregates\\n-- (same pattern as pulse_stats_by_timezone in 007_data_lifecycle.sql).\\n\\n-- \u2500\u2500 1. Fix pulse_refresh_aggregates (Bug #1) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\\n-- Replace overwrite with GREATEST() so repeated calls within a day never\\n-- reduce a count that was already correct.\\n\\nCREATE OR REPLACE FUNCTION analytics.pulse_refresh_aggregates(days_back integer DEFAULT 7)\\nRETURNS void\\nLANGUAGE sql\\nSECURITY DEFINER\\nSET search_path = analytics\\nAS $$\\n INSERT INTO analytics.pulse_aggregates (date, site_id, path, total_views, unique_visitors)\\n SELECT\\n date_trunc('day', created_at)::date AS date,\\n site_id,\\n path,\\n count(*) AS total_views,\\n count(DISTINCT session_id) AS unique_visitors\\n FROM analytics.pulse_events\\n WHERE created_at >= now() - (days_back || ' days')::interval\\n AND event_type = 'pageview'\\n GROUP BY 1, 2, 3\\n ON CONFLICT (date, site_id, path) DO UPDATE SET\\n total_views = GREATEST(analytics.pulse_aggregates.total_views, excluded.total_views),\\n unique_visitors = GREATEST(analytics.pulse_aggregates.unique_visitors, excluded.unique_visitors);\\n$$;\\n\\n-- \u2500\u2500 2. Referrer aggregates table (Bug #3) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\\n-- Stores daily referrer rollups for dates that have been purged from raw events.\\n-- referrer is normalized to '(direct)' for null/empty values, matching the RPC output.\\n\\nCREATE TABLE IF NOT EXISTS analytics.pulse_referrer_aggregates (\\n date date NOT NULL,\\n site_id text NOT NULL,\\n referrer text NOT NULL,\\n total_views bigint NOT NULL DEFAULT 0,\\n unique_visitors bigint NOT NULL DEFAULT 0,\\n PRIMARY KEY (date, site_id, referrer)\\n);\\n\\nALTER TABLE analytics.pulse_referrer_aggregates ENABLE ROW LEVEL SECURITY;\\n\\n-- authenticated users (dashboard) can read aggregates\\nCREATE POLICY \\"Allow authenticated select on pulse_referrer_aggregates\\"\\n ON analytics.pulse_referrer_aggregates\\n FOR SELECT TO authenticated USING (true);\\n\\nGRANT SELECT ON analytics.pulse_referrer_aggregates TO authenticated;\\nGRANT ALL ON analytics.pulse_referrer_aggregates TO service_role;\\n\\n-- \u2500\u2500 3. Update pulse_consolidate_and_cleanup to archive referrer data \u2500\u2500\u2500\u2500\u2500\u2500\\n-- Adds Step 1b: before deleting old events, roll up referrer counts into\\n-- pulse_referrer_aggregates. Uses GREATEST() for idempotent re-runs.\\n\\nCREATE OR REPLACE FUNCTION analytics.pulse_consolidate_and_cleanup(\\n retention_days int DEFAULT 30\\n)\\nRETURNS TABLE (rows_consolidated bigint, rows_deleted bigint)\\nLANGUAGE plpgsql\\nSECURITY DEFINER\\nSET search_path = analytics\\nAS $$\\nDECLARE\\n v_cutoff timestamptz;\\n v_consolidated bigint;\\n v_deleted bigint;\\nBEGIN\\n v_cutoff := now() - make_interval(days => retention_days);\\n\\n -- Step 1a: Roll up old pageview events into daily path-level aggregates\\n WITH inserted AS (\\n INSERT INTO analytics.pulse_aggregates (date, site_id, path, total_views, unique_visitors)\\n SELECT\\n date_trunc('day', created_at)::date AS date,\\n site_id,\\n path,\\n count(*)::bigint AS total_views,\\n count(DISTINCT session_id)::bigint AS unique_visitors\\n FROM analytics.pulse_events\\n WHERE created_at < v_cutoff\\n AND event_type = 'pageview'\\n GROUP BY 1, 2, 3\\n ON CONFLICT (date, site_id, path) DO UPDATE SET\\n total_views = GREATEST(analytics.pulse_aggregates.total_views, excluded.total_views),\\n unique_visitors = GREATEST(analytics.pulse_aggregates.unique_visitors, excluded.unique_visitors)\\n RETURNING 1\\n )\\n SELECT count(*) INTO v_consolidated FROM inserted;\\n\\n -- Step 1b: Roll up old pageview events into daily referrer aggregates\\n -- NOTE: unique_visitors here is per-day distinct sessions. Summing across\\n -- multiple days will over-count visitors who appeared on more than one day,\\n -- but this is an inherent limitation of pre-aggregation and matches the\\n -- approximation already used in pulse_aggregates.\\n INSERT INTO analytics.pulse_referrer_aggregates (date, site_id, referrer, total_views, unique_visitors)\\n SELECT\\n date_trunc('day', created_at)::date AS date,\\n site_id,\\n COALESCE(NULLIF(referrer, ''), '(direct)') AS referrer,\\n count(*) AS total_views,\\n count(DISTINCT session_id) AS unique_visitors\\n FROM analytics.pulse_events\\n WHERE created_at < v_cutoff\\n AND event_type = 'pageview'\\n GROUP BY 1, 2, 3\\n ON CONFLICT (date, site_id, referrer) DO UPDATE SET\\n total_views = GREATEST(analytics.pulse_referrer_aggregates.total_views, excluded.total_views),\\n unique_visitors = GREATEST(analytics.pulse_referrer_aggregates.unique_visitors, excluded.unique_visitors);\\n\\n -- Step 2: Delete all old events (pageviews, vitals, errors, etc.)\\n WITH deleted AS (\\n DELETE FROM analytics.pulse_events\\n WHERE created_at < v_cutoff\\n RETURNING 1\\n )\\n SELECT count(*) INTO v_deleted FROM deleted;\\n\\n RETURN QUERY SELECT v_consolidated, v_deleted;\\nEND;\\n$$;\\n\\n-- \u2500\u2500 4. Rewrite pulse_referrer_stats to union raw events + aggregates \u2500\u2500\u2500\u2500\u2500\u2500\\n-- Follows the same pattern as pulse_stats_by_timezone (007_data_lifecycle.sql):\\n-- - Dates before the oldest raw event \u2192 served from pulse_referrer_aggregates\\n-- - Dates covered by raw events \u2192 served from pulse_events directly\\n-- This ensures referrer data is available regardless of the retention window.\\n\\nCREATE OR REPLACE FUNCTION analytics.pulse_referrer_stats(\\n p_site_id text,\\n p_start_date date DEFAULT NULL,\\n p_end_date date DEFAULT NULL\\n)\\nRETURNS TABLE (\\n referrer text,\\n total_views bigint,\\n unique_visitors bigint\\n)\\nLANGUAGE sql SECURITY DEFINER STABLE\\nSET search_path = analytics\\nAS $$\\n WITH\\n -- Earliest raw pageview date for this site (boundary between raw and archived data)\\n oldest_raw AS (\\n SELECT min(date_trunc('day', created_at)::date) AS min_date\\n FROM analytics.pulse_events\\n WHERE site_id = p_site_id\\n AND event_type = 'pageview'\\n ),\\n -- Historical referrer data from aggregates (dates before oldest raw event)\\n from_aggregates AS (\\n SELECT\\n r.referrer,\\n r.total_views::bigint,\\n r.unique_visitors::bigint\\n FROM analytics.pulse_referrer_aggregates r, oldest_raw o\\n WHERE r.site_id = p_site_id\\n AND r.date >= COALESCE(p_start_date, current_date - 7)\\n AND r.date < COALESCE(p_end_date, current_date) + 1\\n AND (o.min_date IS NULL OR r.date < o.min_date)\\n ),\\n -- Recent referrer data from raw events\\n from_raw AS (\\n SELECT\\n COALESCE(NULLIF(referrer, ''), '(direct)') AS referrer,\\n count(*) AS total_views,\\n count(DISTINCT session_id) AS unique_visitors\\n FROM analytics.pulse_events\\n WHERE site_id = p_site_id\\n AND event_type = 'pageview'\\n AND created_at >= COALESCE(p_start_date, current_date - 7)::timestamptz\\n AND created_at < (COALESCE(p_end_date, current_date) + interval '1 day')::timestamptz\\n GROUP BY 1\\n )\\n SELECT\\n referrer,\\n sum(total_views)::bigint AS total_views,\\n sum(unique_visitors)::bigint AS unique_visitors\\n FROM (\\n SELECT * FROM from_aggregates\\n UNION ALL\\n SELECT * FROM from_raw\\n ) combined\\n GROUP BY referrer\\n ORDER BY total_views DESC\\n LIMIT 20;\\n$$;\\n\\nGRANT EXECUTE ON FUNCTION analytics.pulse_referrer_stats(text, date, date)\\n TO authenticated, service_role;\\nREVOKE EXECUTE ON FUNCTION analytics.pulse_referrer_stats(text, date, date) FROM anon;\\n\\nNOTIFY pgrst, 'reload config';\\nNOTIFY pgrst, 'reload schema';\\n","011_location_aggregation.sql":"-- 011_location_aggregation.sql\\n--\\n-- Adds location aggregation so geo/map data survives the 30-day retention\\n-- window. Follows the same pattern as referrer aggregation in 010.\\n--\\n-- 1. Create pulse_location_aggregates table\\n-- 2. Update pulse_consolidate_and_cleanup to archive location data (Step 1c)\\n-- 3. Rewrite pulse_location_stats to union raw events + aggregates (dual-source)\\n\\n-- \u2500\u2500 1. Location aggregates table \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\\n-- Stores daily location rollups for dates that have been purged from raw events.\\n-- city is normalized to '' for NULL values, matching the GROUP BY output.\\n\\nCREATE TABLE IF NOT EXISTS analytics.pulse_location_aggregates (\\n date date NOT NULL,\\n site_id text NOT NULL,\\n country text NOT NULL,\\n city text NOT NULL DEFAULT '',\\n latitude double precision NOT NULL DEFAULT 0,\\n longitude double precision NOT NULL DEFAULT 0,\\n total_views bigint NOT NULL DEFAULT 0,\\n unique_visitors bigint NOT NULL DEFAULT 0,\\n PRIMARY KEY (date, site_id, country, city)\\n);\\n\\nALTER TABLE analytics.pulse_location_aggregates ENABLE ROW LEVEL SECURITY;\\n\\n-- authenticated users (dashboard) can read aggregates\\nCREATE POLICY \\"Allow authenticated select on pulse_location_aggregates\\"\\n ON analytics.pulse_location_aggregates\\n FOR SELECT TO authenticated USING (true);\\n\\nGRANT SELECT ON analytics.pulse_location_aggregates TO authenticated;\\nGRANT ALL ON analytics.pulse_location_aggregates TO service_role;\\n\\n-- \u2500\u2500 2. Update pulse_consolidate_and_cleanup \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\\n-- Adds Step 1c: archive location data into pulse_location_aggregates\\n-- before deleting old events. Replaces the version from 010.\\n\\nCREATE OR REPLACE FUNCTION analytics.pulse_consolidate_and_cleanup(\\n retention_days int DEFAULT 30\\n)\\nRETURNS TABLE (rows_consolidated bigint, rows_deleted bigint)\\nLANGUAGE plpgsql\\nSECURITY DEFINER\\nSET search_path = analytics\\nAS $$\\nDECLARE\\n v_cutoff timestamptz;\\n v_consolidated bigint;\\n v_deleted bigint;\\nBEGIN\\n v_cutoff := now() - make_interval(days => retention_days);\\n\\n -- Step 1a: Roll up old pageview events into daily path-level aggregates\\n WITH inserted AS (\\n INSERT INTO analytics.pulse_aggregates (date, site_id, path, total_views, unique_visitors)\\n SELECT\\n date_trunc('day', created_at)::date AS date,\\n site_id,\\n path,\\n count(*)::bigint AS total_views,\\n count(DISTINCT session_id)::bigint AS unique_visitors\\n FROM analytics.pulse_events\\n WHERE created_at < v_cutoff\\n AND event_type = 'pageview'\\n GROUP BY 1, 2, 3\\n ON CONFLICT (date, site_id, path) DO UPDATE SET\\n total_views = GREATEST(analytics.pulse_aggregates.total_views, excluded.total_views),\\n unique_visitors = GREATEST(analytics.pulse_aggregates.unique_visitors, excluded.unique_visitors)\\n RETURNING 1\\n )\\n SELECT count(*) INTO v_consolidated FROM inserted;\\n\\n -- Step 1b: Roll up old pageview events into daily referrer aggregates\\n INSERT INTO analytics.pulse_referrer_aggregates (date, site_id, referrer, total_views, unique_visitors)\\n SELECT\\n date_trunc('day', created_at)::date AS date,\\n site_id,\\n COALESCE(NULLIF(referrer, ''), '(direct)') AS referrer,\\n count(*) AS total_views,\\n count(DISTINCT session_id) AS unique_visitors\\n FROM analytics.pulse_events\\n WHERE created_at < v_cutoff\\n AND event_type = 'pageview'\\n GROUP BY 1, 2, 3\\n ON CONFLICT (date, site_id, referrer) DO UPDATE SET\\n total_views = GREATEST(analytics.pulse_referrer_aggregates.total_views, excluded.total_views),\\n unique_visitors = GREATEST(analytics.pulse_referrer_aggregates.unique_visitors, excluded.unique_visitors);\\n\\n -- Step 1c: Roll up old pageview events into daily location aggregates\\n INSERT INTO analytics.pulse_location_aggregates (date, site_id, country, city, latitude, longitude, total_views, unique_visitors)\\n SELECT\\n date_trunc('day', created_at)::date AS date,\\n site_id,\\n country,\\n COALESCE(city, '') AS city,\\n avg(latitude) AS latitude,\\n avg(longitude) AS longitude,\\n count(*) AS total_views,\\n count(DISTINCT session_id) AS unique_visitors\\n FROM analytics.pulse_events\\n WHERE created_at < v_cutoff\\n AND event_type = 'pageview'\\n AND country IS NOT NULL\\n GROUP BY 1, 2, 3, 4\\n ON CONFLICT (date, site_id, country, city) DO UPDATE SET\\n total_views = GREATEST(analytics.pulse_location_aggregates.total_views, excluded.total_views),\\n unique_visitors = GREATEST(analytics.pulse_location_aggregates.unique_visitors, excluded.unique_visitors),\\n latitude = excluded.latitude,\\n longitude = excluded.longitude;\\n\\n -- Step 2: Delete all old events (pageviews, vitals, errors, etc.)\\n WITH deleted AS (\\n DELETE FROM analytics.pulse_events\\n WHERE created_at < v_cutoff\\n RETURNING 1\\n )\\n SELECT count(*) INTO v_deleted FROM deleted;\\n\\n RETURN QUERY SELECT v_consolidated, v_deleted;\\nEND;\\n$$;\\n\\n-- \u2500\u2500 3. Rewrite pulse_location_stats \u2014 dual-source \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\\n-- Follows the same pattern as pulse_referrer_stats (010) and\\n-- pulse_stats_by_timezone (007):\\n-- - Dates before the oldest raw event \u2192 served from pulse_location_aggregates\\n-- - Dates covered by raw events \u2192 served from pulse_events directly\\n-- This ensures location/map data is available regardless of the retention window.\\n\\nCREATE OR REPLACE FUNCTION analytics.pulse_location_stats(\\n p_site_id text,\\n p_start_date date DEFAULT NULL,\\n p_end_date date DEFAULT NULL\\n)\\nRETURNS TABLE (\\n country text,\\n city text,\\n latitude double precision,\\n longitude double precision,\\n total_views bigint,\\n unique_visitors bigint\\n)\\nLANGUAGE sql SECURITY DEFINER STABLE\\nSET search_path = analytics\\nAS $$\\n WITH\\n -- Earliest raw pageview date for this site (boundary between raw and archived data)\\n oldest_raw AS (\\n SELECT min(date_trunc('day', created_at)::date) AS min_date\\n FROM analytics.pulse_events\\n WHERE site_id = p_site_id\\n AND event_type = 'pageview'\\n ),\\n -- Historical location data from aggregates (dates before oldest raw event)\\n from_aggregates AS (\\n SELECT\\n l.country,\\n l.city,\\n l.latitude,\\n l.longitude,\\n l.total_views::bigint,\\n l.unique_visitors::bigint\\n FROM analytics.pulse_location_aggregates l, oldest_raw o\\n WHERE l.site_id = p_site_id\\n AND l.date >= COALESCE(p_start_date, current_date - 7)\\n AND l.date < COALESCE(p_end_date, current_date) + 1\\n AND (o.min_date IS NULL OR l.date < o.min_date)\\n ),\\n -- Recent location data from raw events\\n from_raw AS (\\n SELECT\\n country,\\n city,\\n avg(latitude) AS latitude,\\n avg(longitude) AS longitude,\\n count(*) AS total_views,\\n count(DISTINCT session_id) AS unique_visitors\\n FROM analytics.pulse_events\\n WHERE site_id = p_site_id\\n AND event_type = 'pageview'\\n AND country IS NOT NULL\\n AND created_at >= COALESCE(p_start_date, current_date - 7)::timestamptz\\n AND created_at < (COALESCE(p_end_date, current_date) + interval '1 day')::timestamptz\\n GROUP BY country, city\\n )\\n SELECT\\n country,\\n NULLIF(city, '') AS city,\\n sum(latitude * total_views) / NULLIF(sum(total_views), 0) AS latitude,\\n sum(longitude * total_views) / NULLIF(sum(total_views), 0) AS longitude,\\n sum(total_views)::bigint AS total_views,\\n sum(unique_visitors)::bigint AS unique_visitors\\n FROM (\\n SELECT * FROM from_aggregates\\n UNION ALL\\n SELECT * FROM from_raw\\n ) combined\\n GROUP BY country, city\\n ORDER BY total_views DESC;\\n$$;\\n\\nGRANT EXECUTE ON FUNCTION analytics.pulse_location_stats(text, date, date)\\n TO authenticated, service_role;\\nREVOKE EXECUTE ON FUNCTION analytics.pulse_location_stats(text, date, date) FROM anon;\\n\\nNOTIFY pgrst, 'reload config';\\nNOTIFY pgrst, 'reload schema';\\n","012_event_browser.sql":"-- 012_event_browser.sql\\n--\\n-- Adds server-side paginated event listing and count RPCs for the\\n-- dashboard Events tab. Supports filtering by event_type, path,\\n-- and session_id.\\n\\n-- \u2500\u2500 1. pulse_events_list \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\\n-- Returns a page of raw events, newest first.\\n\\nCREATE OR REPLACE FUNCTION analytics.pulse_events_list(\\n p_site_id text,\\n p_start_date date DEFAULT NULL,\\n p_end_date date DEFAULT NULL,\\n p_event_type text DEFAULT NULL,\\n p_path text DEFAULT NULL,\\n p_session_id text DEFAULT NULL,\\n p_limit int DEFAULT 50,\\n p_offset int DEFAULT 0\\n)\\nRETURNS TABLE (\\n id bigint,\\n event_type text,\\n path text,\\n session_id text,\\n referrer text,\\n country text,\\n city text,\\n meta jsonb,\\n created_at timestamptz\\n)\\nLANGUAGE sql SECURITY DEFINER STABLE\\nSET search_path = analytics\\nAS $$\\n SELECT\\n e.id,\\n e.event_type,\\n e.path,\\n e.session_id,\\n e.referrer,\\n e.country,\\n e.city,\\n e.meta,\\n e.created_at\\n FROM analytics.pulse_events e\\n WHERE e.site_id = p_site_id\\n AND e.created_at >= COALESCE(p_start_date, current_date - 7)::timestamptz\\n AND e.created_at < (COALESCE(p_end_date, current_date) + interval '1 day')::timestamptz\\n AND (p_event_type IS NULL OR e.event_type = p_event_type)\\n AND (p_path IS NULL OR e.path = p_path)\\n AND (p_session_id IS NULL OR e.session_id = p_session_id)\\n ORDER BY e.created_at DESC\\n LIMIT LEAST(p_limit, 100)\\n OFFSET p_offset;\\n$$;\\n\\nGRANT EXECUTE ON FUNCTION analytics.pulse_events_list(text, date, date, text, text, text, int, int)\\n TO authenticated, service_role;\\nREVOKE EXECUTE ON FUNCTION analytics.pulse_events_list(text, date, date, text, text, text, int, int)\\n FROM anon;\\n\\n-- \u2500\u2500 2. pulse_events_count \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\\n-- Returns total matching row count for pagination.\\n\\nCREATE OR REPLACE FUNCTION analytics.pulse_events_count(\\n p_site_id text,\\n p_start_date date DEFAULT NULL,\\n p_end_date date DEFAULT NULL,\\n p_event_type text DEFAULT NULL,\\n p_path text DEFAULT NULL,\\n p_session_id text DEFAULT NULL\\n)\\nRETURNS bigint\\nLANGUAGE sql SECURITY DEFINER STABLE\\nSET search_path = analytics\\nAS $$\\n SELECT count(*)\\n FROM analytics.pulse_events e\\n WHERE e.site_id = p_site_id\\n AND e.created_at >= COALESCE(p_start_date, current_date - 7)::timestamptz\\n AND e.created_at < (COALESCE(p_end_date, current_date) + interval '1 day')::timestamptz\\n AND (p_event_type IS NULL OR e.event_type = p_event_type)\\n AND (p_path IS NULL OR e.path = p_path)\\n AND (p_session_id IS NULL OR e.session_id = p_session_id);\\n$$;\\n\\nGRANT EXECUTE ON FUNCTION analytics.pulse_events_count(text, date, date, text, text, text)\\n TO authenticated, service_role;\\nREVOKE EXECUTE ON FUNCTION analytics.pulse_events_count(text, date, date, text, text, text)\\n FROM anon;\\n\\nNOTIFY pgrst, 'reload config';\\nNOTIFY pgrst, 'reload schema';\\n","013_system_stats.sql":"-- 013_system_stats.sql\\n--\\n-- Adds a system diagnostics RPC for the dashboard System tab.\\n-- Returns key-value pairs about pipeline health, aggregation status,\\n-- and configuration.\\n\\nCREATE OR REPLACE FUNCTION analytics.pulse_system_stats(p_site_id text)\\nRETURNS TABLE (stat_key text, stat_value text)\\nLANGUAGE sql SECURITY DEFINER STABLE\\nSET search_path = analytics\\nAS $$\\n -- Event counts by type\\n SELECT 'total_events', count(*)::text\\n FROM analytics.pulse_events WHERE site_id = p_site_id\\n\\n UNION ALL\\n SELECT 'pageview_count', count(*)::text\\n FROM analytics.pulse_events WHERE site_id = p_site_id AND event_type = 'pageview'\\n\\n UNION ALL\\n SELECT 'vitals_count', count(*)::text\\n FROM analytics.pulse_events WHERE site_id = p_site_id AND event_type = 'vitals'\\n\\n UNION ALL\\n SELECT 'error_count', count(*)::text\\n FROM analytics.pulse_events WHERE site_id = p_site_id AND event_type = 'error'\\n\\n UNION ALL\\n SELECT 'server_error_count', count(*)::text\\n FROM analytics.pulse_events WHERE site_id = p_site_id AND event_type = 'server_error'\\n\\n UNION ALL\\n SELECT 'custom_count', count(*)::text\\n FROM analytics.pulse_events WHERE site_id = p_site_id AND event_type = 'custom'\\n\\n -- Event time range\\n UNION ALL\\n SELECT 'oldest_event', min(created_at)::text\\n FROM analytics.pulse_events WHERE site_id = p_site_id\\n\\n UNION ALL\\n SELECT 'newest_event', max(created_at)::text\\n FROM analytics.pulse_events WHERE site_id = p_site_id\\n\\n -- Aggregate table stats\\n UNION ALL\\n SELECT 'aggregates_rows', count(*)::text\\n FROM analytics.pulse_aggregates WHERE site_id = p_site_id\\n\\n UNION ALL\\n SELECT 'aggregates_oldest', min(date)::text\\n FROM analytics.pulse_aggregates WHERE site_id = p_site_id\\n\\n UNION ALL\\n SELECT 'aggregates_newest', max(date)::text\\n FROM analytics.pulse_aggregates WHERE site_id = p_site_id\\n\\n UNION ALL\\n SELECT 'referrer_aggregates_rows', count(*)::text\\n FROM analytics.pulse_referrer_aggregates WHERE site_id = p_site_id\\n\\n UNION ALL\\n SELECT 'location_aggregates_rows', count(*)::text\\n FROM analytics.pulse_location_aggregates WHERE site_id = p_site_id\\n\\n -- Distinct counts\\n UNION ALL\\n SELECT 'distinct_sessions', count(DISTINCT session_id)::text\\n FROM analytics.pulse_events WHERE site_id = p_site_id\\n\\n UNION ALL\\n SELECT 'distinct_paths', count(DISTINCT path)::text\\n FROM analytics.pulse_events WHERE site_id = p_site_id AND event_type = 'pageview';\\n$$;\\n\\nGRANT EXECUTE ON FUNCTION analytics.pulse_system_stats(text)\\n TO authenticated, service_role;\\nREVOKE EXECUTE ON FUNCTION analytics.pulse_system_stats(text) FROM anon;\\n\\nNOTIFY pgrst, 'reload config';\\nNOTIFY pgrst, 'reload schema';\\n"}`
|
|
428
482
|
);
|
|
429
483
|
function writeMigration() {
|
|
430
484
|
console.log(" Writing database migration...\n");
|
|
@@ -452,6 +506,7 @@ function appendEnvExample() {
|
|
|
452
506
|
const content = import_node_fs6.default.readFileSync(envExamplePath, "utf8");
|
|
453
507
|
const entries = [
|
|
454
508
|
{ key: "PULSE_SECRET", value: "" },
|
|
509
|
+
{ key: "CRON_SECRET", value: "" },
|
|
455
510
|
{ key: "SUPABASE_SERVICE_ROLE_KEY", value: "" }
|
|
456
511
|
];
|
|
457
512
|
const missing = entries.filter((e) => !content.includes(e.key));
|
|
@@ -480,6 +535,7 @@ async function main() {
|
|
|
480
535
|
console.log(" NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=<your-anon-key>");
|
|
481
536
|
console.log(" SUPABASE_SERVICE_ROLE_KEY=<your-service-role-key>");
|
|
482
537
|
console.log(" PULSE_SECRET=<a-random-string-at-least-16-chars>");
|
|
538
|
+
console.log(" CRON_SECRET=<a-random-string-for-cron-auth>");
|
|
483
539
|
console.log(" 2. Run the database migration:");
|
|
484
540
|
console.log(" npx supabase link");
|
|
485
541
|
console.log(" npx supabase db push");
|
|
@@ -487,7 +543,11 @@ async function main() {
|
|
|
487
543
|
console.log(" allow the PulseKit paths through:");
|
|
488
544
|
console.log(' !request.nextUrl.pathname.startsWith("/api/pulse")');
|
|
489
545
|
console.log(' !request.nextUrl.pathname.startsWith("/admin/analytics")');
|
|
490
|
-
console.log(" 4.
|
|
546
|
+
console.log(" 4. If deploying to Vercel, add CRON_SECRET to your project");
|
|
547
|
+
console.log(" environment variables to enable automatic aggregation.");
|
|
548
|
+
console.log(" Not on Vercel? Call the endpoints from any cron service");
|
|
549
|
+
console.log(" with the header: Authorization: Bearer <PULSE_SECRET>");
|
|
550
|
+
console.log(" 5. Start your dev server and visit /admin/analytics\n");
|
|
491
551
|
}
|
|
492
552
|
main().catch((err) => {
|
|
493
553
|
console.error("\n Error:", err.message || err);
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts","../src/detect.ts","../src/install.ts","../src/scaffold.ts","../src/inject.ts","../src/inject-instrumentation.ts","../src/migration.ts"],"sourcesContent":["import fs from \"node:fs\";\nimport path from \"node:path\";\nimport { detectPackageManager, validateNextJsProject } from \"./detect\";\nimport { installPackages } from \"./install\";\nimport { scaffoldFiles } from \"./scaffold\";\nimport { injectPulseTracker } from \"./inject\";\nimport { injectInstrumentation } from \"./inject-instrumentation\";\nimport { writeMigration } from \"./migration\";\n\nfunction appendEnvExample(): void {\n const envExamplePath = path.join(process.cwd(), \".env.example\");\n if (!fs.existsSync(envExamplePath)) return;\n\n const content = fs.readFileSync(envExamplePath, \"utf8\");\n const entries: { key: string; value: string }[] = [\n { key: \"PULSE_SECRET\", value: \"\" },\n { key: \"SUPABASE_SERVICE_ROLE_KEY\", value: \"\" },\n ];\n\n const missing = entries.filter((e) => !content.includes(e.key));\n if (missing.length === 0) return;\n\n const block =\n \"\\n# PulseKit\\n\" +\n missing.map((e) => `${e.key}=${e.value}`).join(\"\\n\") +\n \"\\n\";\n fs.appendFileSync(envExamplePath, block, \"utf8\");\n console.log(\" Updated .env.example with PulseKit variables.\\n\");\n}\n\nasync function main() {\n console.log(\"\\n create-pulsekit\\n\");\n console.log(\" Setting up PulseKit analytics in your Next.js project.\\n\");\n\n const pm = detectPackageManager();\n console.log(` Detected package manager: ${pm}\\n`);\n\n validateNextJsProject();\n\n await installPackages(pm);\n\n scaffoldFiles();\n\n await injectPulseTracker();\n\n await injectInstrumentation();\n\n writeMigration();\n\n appendEnvExample();\n\n console.log(\"\\n Done! PulseKit has been added to your project.\\n\");\n console.log(\" To finish setup:\");\n console.log(\" 1. Add these variables to .env.local:\");\n console.log(\" NEXT_PUBLIC_SUPABASE_URL=<your-supabase-url>\");\n console.log(\" NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=<your-anon-key>\");\n console.log(\" SUPABASE_SERVICE_ROLE_KEY=<your-service-role-key>\");\n console.log(\" PULSE_SECRET=<a-random-string-at-least-16-chars>\");\n console.log(\" 2. Run the database migration:\");\n console.log(\" npx supabase link\");\n console.log(\" npx supabase db push\");\n console.log(\" 3. If your middleware protects routes (e.g. Supabase auth),\");\n console.log(\" allow the PulseKit paths through:\");\n console.log(' !request.nextUrl.pathname.startsWith(\"/api/pulse\")');\n console.log(' !request.nextUrl.pathname.startsWith(\"/admin/analytics\")');\n console.log(\" 4. Start your dev server and visit /admin/analytics\\n\");\n}\n\nmain().catch((err) => {\n console.error(\"\\n Error:\", err.message || err);\n process.exit(1);\n});\n","import fs from \"node:fs\";\nimport path from \"node:path\";\n\nexport type PackageManager = \"npm\" | \"pnpm\" | \"yarn\" | \"bun\";\n\nexport function detectPackageManager(): PackageManager {\n const cwd = process.cwd();\n\n if (\n fs.existsSync(path.join(cwd, \"bun.lock\")) ||\n fs.existsSync(path.join(cwd, \"bun.lockb\"))\n ) {\n return \"bun\";\n }\n if (fs.existsSync(path.join(cwd, \"pnpm-lock.yaml\"))) {\n return \"pnpm\";\n }\n if (fs.existsSync(path.join(cwd, \"yarn.lock\"))) {\n return \"yarn\";\n }\n return \"npm\";\n}\n\nexport function validateNextJsProject(): void {\n const cwd = process.cwd();\n\n const pkgPath = path.join(cwd, \"package.json\");\n if (!fs.existsSync(pkgPath)) {\n throw new Error(\n \"No package.json found. Run this command from the root of a Next.js project.\"\n );\n }\n\n const pkg = JSON.parse(fs.readFileSync(pkgPath, \"utf8\"));\n const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };\n if (!allDeps[\"next\"]) {\n throw new Error(\n \"next is not listed in package.json. This command requires a Next.js project.\"\n );\n }\n\n if (!fs.existsSync(getAppDir())) {\n throw new Error(\n \"No app/ directory found. PulseKit requires the Next.js App Router.\"\n );\n }\n}\n\nexport function getAppDir(): string {\n const cwd = process.cwd();\n const srcAppDir = path.join(cwd, \"src\", \"app\");\n if (fs.existsSync(srcAppDir)) {\n return srcAppDir;\n }\n return path.join(cwd, \"app\");\n}\n","import { execSync } from \"node:child_process\";\nimport type { PackageManager } from \"./detect\";\n\nconst PACKAGES = [\n \"@pulsekit/core\",\n \"@pulsekit/next\",\n \"@pulsekit/react\",\n \"@supabase/supabase-js@latest\",\n];\n\nexport async function installPackages(pm: PackageManager): Promise<void> {\n console.log(\" Installing packages...\\n\");\n\n const commands: Record<PackageManager, string> = {\n npm: `npm install ${PACKAGES.join(\" \")}`,\n pnpm: `pnpm add ${PACKAGES.join(\" \")}`,\n yarn: `yarn add ${PACKAGES.join(\" \")}`,\n bun: `bun add ${PACKAGES.join(\" \")}`,\n };\n\n const cmd = commands[pm];\n console.log(` > ${cmd}\\n`);\n\n try {\n execSync(cmd, { cwd: process.cwd(), stdio: \"inherit\" });\n } catch {\n throw new Error(\n `Package installation failed. Try running manually:\\n ${cmd}`\n );\n }\n}\n","import fs from \"node:fs\";\nimport path from \"node:path\";\nimport { getAppDir } from \"./detect\";\n\nexport function scaffoldFiles(): void {\n console.log(\" Scaffolding files...\\n\");\n\n const appDir = getAppDir();\n\n const pulseRoute = `import { createPulseHandler } from \"@pulsekit/next\";\nimport { createClient } from \"@supabase/supabase-js\";\n\nconst supabase = createClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!\n);\n\nexport const POST = createPulseHandler({\n supabase,\n config: {\n siteId: \"default\",\n secret: process.env.PULSE_SECRET,\n },\n});\n`;\n\n const refreshRoute = `import { createRefreshHandler, withPulseAuth } from \"@pulsekit/next\";\nimport { createClient } from \"@supabase/supabase-js\";\n\nconst supabase = createClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!\n);\n\nexport const POST = withPulseAuth(createRefreshHandler({ supabase }));\n`;\n\n const consolidateRoute = `import { createConsolidateHandler, withPulseAuth } from \"@pulsekit/next\";\nimport { createClient } from \"@supabase/supabase-js\";\n\nconst supabase = createClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!\n);\n\nexport const POST = withPulseAuth(createConsolidateHandler({ supabase }));\n`;\n\n const authRoute = `import { createPulseAuthHandler } from \"@pulsekit/next\";\n\nconst handler = createPulseAuthHandler({ secret: process.env.PULSE_SECRET! });\n\nexport const POST = handler;\nexport const DELETE = handler;\n`;\n\n const dashboardPage = `import { Suspense } from \"react\";\nimport { createClient } from \"@supabase/supabase-js\";\nimport { PulseDashboard, PulseAuthGate } from \"@pulsekit/react\";\nimport { getPulseTimezone } from \"@pulsekit/next\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport \"@pulsekit/react/pulse.css\";\n\nconst supabase = createClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!\n);\n\nasync function Dashboard() {\n const timezone = await getPulseTimezone();\n\n return (\n <PulseDashboard\n supabase={supabase}\n siteId=\"default\"\n timeframe=\"7d\"\n timezone={timezone}\n />\n );\n}\n\nexport default function AnalyticsPage() {\n return (\n <Suspense fallback={<div style={{ display: \"flex\", alignItems: \"center\", justifyContent: \"center\", minHeight: \"100vh\", padding: \"1.5rem\" }}><Spinner style={{ width: 24, height: 24 }} /></div>}>\n <PulseAuthGate secret={process.env.PULSE_SECRET!}>\n <Dashboard />\n </PulseAuthGate>\n </Suspense>\n );\n}\n`;\n\n const trackerWrapper = `import { PulseTracker } from \"@pulsekit/next/client\";\nimport { createPulseIngestionToken } from \"@pulsekit/next\";\nimport { connection } from \"next/server\";\n\nexport default async function PulseTrackerWrapper() {\n await connection();\n const token = process.env.PULSE_SECRET\n ? await createPulseIngestionToken(process.env.PULSE_SECRET)\n : undefined;\n\n return (\n <PulseTracker\n excludePaths={[\"/admin/analytics\"]}\n token={token}\n />\n );\n}\n`;\n\n // Scaffold the spinner component if not already present\n const spinnerContent = `function Spinner({ style, ...props }: React.ComponentProps<\"svg\">) {\n return (\n <>\n <style>{\\`@keyframes pulsekit-spin { to { transform: rotate(360deg) } }\\`}</style>\n <svg\n role=\"status\"\n aria-label=\"Loading\"\n width=\"16\"\n height=\"16\"\n xmlns=\"http://www.w3.org/2000/svg\"\n fill=\"none\"\n viewBox=\"0 0 24 24\"\n style={{ animation: \"pulsekit-spin 1s linear infinite\", ...style }}\n {...props}\n >\n <circle opacity={0.25} cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" strokeWidth=\"4\" />\n <path opacity={0.75} fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z\" />\n </svg>\n </>\n );\n}\n\nexport { Spinner }\n`;\n\n const cwd = process.cwd();\n const componentsBase = appDir.includes(path.join(\"src\", \"app\"))\n ? path.join(cwd, \"src\", \"components\", \"ui\")\n : path.join(cwd, \"components\", \"ui\");\n\n const spinnerPath = path.join(componentsBase, \"spinner.tsx\");\n fs.mkdirSync(componentsBase, { recursive: true });\n if (fs.existsSync(spinnerPath)) {\n console.log(\" Skipped (already exists): components/ui/spinner.tsx\");\n } else {\n fs.writeFileSync(spinnerPath, spinnerContent, \"utf8\");\n console.log(\" Created: components/ui/spinner.tsx\");\n }\n\n // Scaffold the tracker wrapper component\n const componentsDir = appDir.includes(path.join(\"src\", \"app\"))\n ? path.join(cwd, \"src\", \"components\")\n : path.join(cwd, \"components\");\n const wrapperPath = path.join(componentsDir, \"pulse-tracker-wrapper.tsx\");\n fs.mkdirSync(componentsDir, { recursive: true });\n if (fs.existsSync(wrapperPath)) {\n console.log(\" Skipped (already exists): components/pulse-tracker-wrapper.tsx\");\n } else {\n fs.writeFileSync(wrapperPath, trackerWrapper, \"utf8\");\n console.log(\" Created: components/pulse-tracker-wrapper.tsx\");\n }\n\n const files = [\n { rel: \"api/pulse/route.ts\", content: pulseRoute },\n { rel: \"api/pulse/auth/route.ts\", content: authRoute },\n { rel: \"api/pulse/refresh-aggregates/route.ts\", content: refreshRoute },\n { rel: \"api/pulse/consolidate/route.ts\", content: consolidateRoute },\n { rel: \"admin/analytics/page.tsx\", content: dashboardPage },\n ];\n\n for (const { rel, content } of files) {\n const fullPath = path.join(appDir, rel);\n fs.mkdirSync(path.dirname(fullPath), { recursive: true });\n\n if (fs.existsSync(fullPath)) {\n console.log(` Skipped (already exists): ${rel}`);\n continue;\n }\n\n fs.writeFileSync(fullPath, content, \"utf8\");\n console.log(` Created: ${rel}`);\n }\n\n console.log(\"\");\n}\n","import fs from \"node:fs\";\nimport path from \"node:path\";\nimport { getAppDir } from \"./detect\";\n\nexport async function injectPulseTracker(): Promise<void> {\n console.log(\" Injecting PulseTracker into layout...\\n\");\n\n const appDir = getAppDir();\n\n const candidates = [\n path.join(appDir, \"layout.tsx\"),\n path.join(appDir, \"layout.jsx\"),\n path.join(appDir, \"layout.js\"),\n ];\n\n let foundPath: string | null = null;\n for (const candidate of candidates) {\n if (fs.existsSync(candidate)) {\n foundPath = candidate;\n break;\n }\n }\n\n if (!foundPath) {\n printManualInstructions();\n return;\n }\n\n let content = fs.readFileSync(foundPath, \"utf8\");\n\n if (content.includes(\"PulseTrackerWrapper\") || content.includes(\"PulseTracker\")) {\n console.log(\" PulseTracker already present in layout. Skipping.\\n\");\n return;\n }\n\n // Add imports after the last existing import\n const importStatements = [\n 'import { Suspense } from \"react\";',\n 'import PulseTrackerWrapper from \"@/components/pulse-tracker-wrapper\";',\n ];\n\n const importRegex = /^import\\s.+$/gm;\n let lastImportEnd = -1;\n let match: RegExpExecArray | null;\n while ((match = importRegex.exec(content)) !== null) {\n lastImportEnd = match.index + match[0].length;\n }\n\n // Only add imports that aren't already present\n const newImports = importStatements.filter((stmt) => !content.includes(stmt));\n const importBlock = newImports.join(\"\\n\");\n\n if (importBlock) {\n if (lastImportEnd === -1) {\n content = importBlock + \"\\n\" + content;\n } else {\n content =\n content.slice(0, lastImportEnd) +\n \"\\n\" +\n importBlock +\n content.slice(lastImportEnd);\n }\n }\n\n // Inject <Suspense><PulseTrackerWrapper /></Suspense> before </body>\n const bodyCloseIndex = content.lastIndexOf(\"</body>\");\n if (bodyCloseIndex === -1) {\n printManualInstructions();\n return;\n }\n\n // Detect indentation from the </body> line\n const lineStart = content.lastIndexOf(\"\\n\", bodyCloseIndex) + 1;\n const indent = content.slice(lineStart, bodyCloseIndex).match(/^\\s*/)?.[0] ?? \" \";\n const trackerJsx = [\n `${indent} <Suspense>`,\n `${indent} <PulseTrackerWrapper />`,\n `${indent} </Suspense>`,\n ].join(\"\\n\") + \"\\n\";\n\n content =\n content.slice(0, lineStart) + trackerJsx + content.slice(lineStart);\n\n fs.writeFileSync(foundPath, content, \"utf8\");\n console.log(\n ` Modified: ${path.relative(process.cwd(), foundPath)}\\n`\n );\n}\n\nfunction printManualInstructions(): void {\n console.log(\n \" Could not auto-inject PulseTracker. Add it manually to your layout:\\n\"\n );\n console.log(' import { Suspense } from \"react\";');\n console.log(' import PulseTrackerWrapper from \"@/components/pulse-tracker-wrapper\";');\n console.log(\"\");\n console.log(\" // Add inside your <body> tag:\");\n console.log(\" <Suspense>\");\n console.log(\" <PulseTrackerWrapper />\");\n console.log(\" </Suspense>\\n\");\n}\n","import fs from \"node:fs\";\nimport path from \"node:path\";\nimport { getAppDir } from \"./detect\";\n\nconst FULL_CONTENT = `import { createClient } from \"@supabase/supabase-js\";\nimport { createPulseErrorReporter } from \"@pulsekit/next\";\n\nconst supabase = createClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!\n);\n\nexport const onRequestError = createPulseErrorReporter({\n supabase,\n siteId: \"default\",\n});\n`;\n\nconst IMPORT_LINE = 'import { createPulseErrorReporter } from \"@pulsekit/next\";';\n\nconst EXPORT_BLOCK = `\nexport const onRequestError = createPulseErrorReporter({\n supabase,\n siteId: \"default\",\n});\n`;\n\nexport async function injectInstrumentation(): Promise<void> {\n console.log(\" Setting up error reporting instrumentation...\\n\");\n\n const appDir = getAppDir();\n // src/app -> src/instrumentation.ts; app/ -> instrumentation.ts\n const useSrc = appDir.includes(path.join(\"src\", \"app\"));\n const baseDir = useSrc ? path.join(process.cwd(), \"src\") : process.cwd();\n\n const candidates = [\n path.join(baseDir, \"instrumentation.ts\"),\n path.join(baseDir, \"instrumentation.js\"),\n ];\n\n let foundPath: string | null = null;\n for (const candidate of candidates) {\n if (fs.existsSync(candidate)) {\n foundPath = candidate;\n break;\n }\n }\n\n // Case 1: No file exists — create it\n if (!foundPath) {\n const targetPath = path.join(baseDir, \"instrumentation.ts\");\n fs.writeFileSync(targetPath, FULL_CONTENT, \"utf8\");\n console.log(` Created: ${path.relative(process.cwd(), targetPath)}\\n`);\n return;\n }\n\n const content = fs.readFileSync(foundPath, \"utf8\");\n\n // Case 3: Already has onRequestError — skip\n if (content.includes(\"onRequestError\")) {\n console.log(\" onRequestError already present in instrumentation. Skipping.\\n\");\n console.log(\" To add PulseKit error reporting manually, add:\\n\");\n console.log(` ${IMPORT_LINE}`);\n console.log(\"\");\n console.log(\" export const onRequestError = createPulseErrorReporter({\");\n console.log(\" supabase,\");\n console.log(' siteId: \"default\",');\n console.log(\" });\\n\");\n return;\n }\n\n // Case 2: File exists without onRequestError — append\n const importRegex = /^import\\s.+$/gm;\n let lastImportEnd = -1;\n let match: RegExpExecArray | null;\n while ((match = importRegex.exec(content)) !== null) {\n lastImportEnd = match.index + match[0].length;\n }\n\n let updated: string;\n if (lastImportEnd === -1) {\n updated = IMPORT_LINE + \"\\n\" + content + EXPORT_BLOCK;\n } else {\n updated =\n content.slice(0, lastImportEnd) +\n \"\\n\" +\n IMPORT_LINE +\n content.slice(lastImportEnd) +\n EXPORT_BLOCK;\n }\n\n fs.writeFileSync(foundPath, updated, \"utf8\");\n console.log(` Modified: ${path.relative(process.cwd(), foundPath)}\\n`);\n}\n","import fs from \"node:fs\";\nimport path from \"node:path\";\n\nconst SQL_MAP: Record<string, string> = JSON.parse(\n process.env.__EMBEDDED_SQL__!\n);\n\nexport function writeMigration(): void {\n console.log(\" Writing database migration...\\n\");\n\n const supabaseDir = path.join(process.cwd(), \"supabase\", \"migrations\");\n fs.mkdirSync(supabaseDir, { recursive: true });\n\n // Combine all SQL files into a single migration\n const files = Object.keys(SQL_MAP).sort();\n const combined = files\n .map((file) => `-- ${file}\\n${SQL_MAP[file]}`)\n .join(\"\\n\\n\");\n\n // Use a fixed timestamp so re-running doesn't create duplicates\n const filename = \"20250101000000_pulse_analytics.sql\";\n const fullPath = path.join(supabaseDir, filename);\n\n if (fs.existsSync(fullPath)) {\n console.log(` Skipped (already exists): supabase/migrations/${filename}\\n`);\n return;\n }\n\n fs.writeFileSync(fullPath, combined, \"utf8\");\n console.log(` Created: supabase/migrations/${filename}\\n`);\n}\n"],"mappings":"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","names":["import_node_fs","import_node_path","fs","path","import_node_fs","import_node_path","path","fs","import_node_fs","import_node_path","path","fs","import_node_fs","import_node_path","path","fs","import_node_fs","import_node_path","path","fs","path","fs"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/detect.ts","../src/install.ts","../src/scaffold.ts","../src/inject.ts","../src/inject-instrumentation.ts","../src/migration.ts"],"sourcesContent":["import fs from \"node:fs\";\nimport path from \"node:path\";\nimport { detectPackageManager, validateNextJsProject } from \"./detect\";\nimport { installPackages } from \"./install\";\nimport { scaffoldFiles } from \"./scaffold\";\nimport { injectPulseTracker } from \"./inject\";\nimport { injectInstrumentation } from \"./inject-instrumentation\";\nimport { writeMigration } from \"./migration\";\n\nfunction appendEnvExample(): void {\n const envExamplePath = path.join(process.cwd(), \".env.example\");\n if (!fs.existsSync(envExamplePath)) return;\n\n const content = fs.readFileSync(envExamplePath, \"utf8\");\n const entries: { key: string; value: string }[] = [\n { key: \"PULSE_SECRET\", value: \"\" },\n { key: \"CRON_SECRET\", value: \"\" },\n { key: \"SUPABASE_SERVICE_ROLE_KEY\", value: \"\" },\n ];\n\n const missing = entries.filter((e) => !content.includes(e.key));\n if (missing.length === 0) return;\n\n const block =\n \"\\n# PulseKit\\n\" +\n missing.map((e) => `${e.key}=${e.value}`).join(\"\\n\") +\n \"\\n\";\n fs.appendFileSync(envExamplePath, block, \"utf8\");\n console.log(\" Updated .env.example with PulseKit variables.\\n\");\n}\n\nasync function main() {\n console.log(\"\\n create-pulsekit\\n\");\n console.log(\" Setting up PulseKit analytics in your Next.js project.\\n\");\n\n const pm = detectPackageManager();\n console.log(` Detected package manager: ${pm}\\n`);\n\n validateNextJsProject();\n\n await installPackages(pm);\n\n scaffoldFiles();\n\n await injectPulseTracker();\n\n await injectInstrumentation();\n\n writeMigration();\n\n appendEnvExample();\n\n console.log(\"\\n Done! PulseKit has been added to your project.\\n\");\n console.log(\" To finish setup:\");\n console.log(\" 1. Add these variables to .env.local:\");\n console.log(\" NEXT_PUBLIC_SUPABASE_URL=<your-supabase-url>\");\n console.log(\" NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=<your-anon-key>\");\n console.log(\" SUPABASE_SERVICE_ROLE_KEY=<your-service-role-key>\");\n console.log(\" PULSE_SECRET=<a-random-string-at-least-16-chars>\");\n console.log(\" CRON_SECRET=<a-random-string-for-cron-auth>\");\n console.log(\" 2. Run the database migration:\");\n console.log(\" npx supabase link\");\n console.log(\" npx supabase db push\");\n console.log(\" 3. If your middleware protects routes (e.g. Supabase auth),\");\n console.log(\" allow the PulseKit paths through:\");\n console.log(' !request.nextUrl.pathname.startsWith(\"/api/pulse\")');\n console.log(' !request.nextUrl.pathname.startsWith(\"/admin/analytics\")');\n console.log(\" 4. If deploying to Vercel, add CRON_SECRET to your project\");\n console.log(\" environment variables to enable automatic aggregation.\");\n console.log(\" Not on Vercel? Call the endpoints from any cron service\");\n console.log(\" with the header: Authorization: Bearer <PULSE_SECRET>\");\n console.log(\" 5. Start your dev server and visit /admin/analytics\\n\");\n}\n\nmain().catch((err) => {\n console.error(\"\\n Error:\", err.message || err);\n process.exit(1);\n});\n","import fs from \"node:fs\";\nimport path from \"node:path\";\n\nexport type PackageManager = \"npm\" | \"pnpm\" | \"yarn\" | \"bun\";\n\nexport function detectPackageManager(): PackageManager {\n const cwd = process.cwd();\n\n if (\n fs.existsSync(path.join(cwd, \"bun.lock\")) ||\n fs.existsSync(path.join(cwd, \"bun.lockb\"))\n ) {\n return \"bun\";\n }\n if (fs.existsSync(path.join(cwd, \"pnpm-lock.yaml\"))) {\n return \"pnpm\";\n }\n if (fs.existsSync(path.join(cwd, \"yarn.lock\"))) {\n return \"yarn\";\n }\n return \"npm\";\n}\n\nexport function validateNextJsProject(): void {\n const cwd = process.cwd();\n\n const pkgPath = path.join(cwd, \"package.json\");\n if (!fs.existsSync(pkgPath)) {\n throw new Error(\n \"No package.json found. Run this command from the root of a Next.js project.\"\n );\n }\n\n const pkg = JSON.parse(fs.readFileSync(pkgPath, \"utf8\"));\n const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };\n if (!allDeps[\"next\"]) {\n throw new Error(\n \"next is not listed in package.json. This command requires a Next.js project.\"\n );\n }\n\n if (!fs.existsSync(getAppDir())) {\n throw new Error(\n \"No app/ directory found. PulseKit requires the Next.js App Router.\"\n );\n }\n}\n\nexport function getAppDir(): string {\n const cwd = process.cwd();\n const srcAppDir = path.join(cwd, \"src\", \"app\");\n if (fs.existsSync(srcAppDir)) {\n return srcAppDir;\n }\n return path.join(cwd, \"app\");\n}\n","import { execSync } from \"node:child_process\";\nimport type { PackageManager } from \"./detect\";\n\nconst PACKAGES = [\n \"@pulsekit/core\",\n \"@pulsekit/next\",\n \"@pulsekit/react\",\n \"@supabase/supabase-js@latest\",\n];\n\nexport async function installPackages(pm: PackageManager): Promise<void> {\n console.log(\" Installing packages...\\n\");\n\n const commands: Record<PackageManager, string> = {\n npm: `npm install ${PACKAGES.join(\" \")}`,\n pnpm: `pnpm add ${PACKAGES.join(\" \")}`,\n yarn: `yarn add ${PACKAGES.join(\" \")}`,\n bun: `bun add ${PACKAGES.join(\" \")}`,\n };\n\n const cmd = commands[pm];\n console.log(` > ${cmd}\\n`);\n\n try {\n execSync(cmd, { cwd: process.cwd(), stdio: \"inherit\" });\n } catch {\n throw new Error(\n `Package installation failed. Try running manually:\\n ${cmd}`\n );\n }\n}\n","import fs from \"node:fs\";\nimport path from \"node:path\";\nimport { getAppDir } from \"./detect\";\n\nexport function scaffoldFiles(): void {\n console.log(\" Scaffolding files...\\n\");\n\n const appDir = getAppDir();\n\n const pulseRoute = `import { createPulseHandler } from \"@pulsekit/next\";\nimport { createClient } from \"@supabase/supabase-js\";\n\nconst supabase = createClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.SUPABASE_SERVICE_ROLE_KEY!\n);\n\nexport const POST = createPulseHandler({\n supabase,\n config: {\n siteId: \"default\",\n secret: process.env.PULSE_SECRET,\n },\n});\n`;\n\n const refreshRoute = `import { createRefreshHandler, withPulseAuth } from \"@pulsekit/next\";\nimport { createClient } from \"@supabase/supabase-js\";\n\nconst supabase = createClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.SUPABASE_SERVICE_ROLE_KEY!\n);\n\nconst handler = withPulseAuth(createRefreshHandler({ supabase }));\nexport const GET = handler;\nexport const POST = handler;\n`;\n\n const consolidateRoute = `import { createConsolidateHandler, withPulseAuth } from \"@pulsekit/next\";\nimport { createClient } from \"@supabase/supabase-js\";\n\nconst supabase = createClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.SUPABASE_SERVICE_ROLE_KEY!\n);\n\nconst handler = withPulseAuth(createConsolidateHandler({ supabase }));\nexport const GET = handler;\nexport const POST = handler;\n`;\n\n const authRoute = `import { createPulseAuthHandler } from \"@pulsekit/next\";\n\nconst handler = createPulseAuthHandler({ secret: process.env.PULSE_SECRET! });\n\nexport const POST = handler;\nexport const DELETE = handler;\n`;\n\n const dashboardPage = `import { Suspense } from \"react\";\nimport { createClient } from \"@supabase/supabase-js\";\nimport { PulseDashboard, PulseAuthGate } from \"@pulsekit/react\";\nimport { getPulseTimezone } from \"@pulsekit/next\";\nimport type { Timeframe } from \"@pulsekit/core\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport \"@pulsekit/react/pulse.css\";\n\nconst supabase = createClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.SUPABASE_SERVICE_ROLE_KEY!\n);\n\nasync function Dashboard(props: {\n timeframe: Timeframe;\n range: \"7d\" | \"30d\";\n tab: string;\n eventType?: string;\n eventPath?: string;\n eventSession?: string;\n eventPage?: number;\n}) {\n const timezone = await getPulseTimezone();\n\n return (\n <PulseDashboard\n supabase={supabase}\n siteId=\"default\"\n timezone={timezone}\n {...props}\n />\n );\n}\n\nexport default async function AnalyticsPage({\n searchParams,\n}: {\n searchParams: Promise<{\n from?: string;\n to?: string;\n tab?: string;\n range?: string;\n eventType?: string;\n eventPath?: string;\n eventSession?: string;\n eventPage?: string;\n }>;\n}) {\n const params = await searchParams;\n const timeframe: Timeframe = params.from && params.to ? { from: params.from, to: params.to } : \"7d\";\n\n return (\n <Suspense fallback={<div style={{ display: \"flex\", alignItems: \"center\", justifyContent: \"center\", minHeight: \"100vh\", padding: \"1.5rem\" }}><Spinner style={{ width: 24, height: 24 }} /></div>}>\n <PulseAuthGate secret={process.env.PULSE_SECRET!}>\n <Dashboard\n timeframe={timeframe}\n range={params.range === \"30d\" ? \"30d\" : \"7d\"}\n tab={params.tab || \"traffic\"}\n eventType={params.eventType}\n eventPath={params.eventPath}\n eventSession={params.eventSession}\n eventPage={params.eventPage ? parseInt(params.eventPage, 10) : undefined}\n />\n </PulseAuthGate>\n </Suspense>\n );\n}\n`;\n\n const trackerWrapper = `import { PulseTracker } from \"@pulsekit/next/client\";\nimport { createPulseIngestionToken } from \"@pulsekit/next\";\nimport { connection } from \"next/server\";\n\nexport default async function PulseTrackerWrapper() {\n await connection();\n const token = process.env.PULSE_SECRET\n ? await createPulseIngestionToken(process.env.PULSE_SECRET)\n : undefined;\n\n return (\n <PulseTracker\n excludePaths={[\"/admin/analytics\"]}\n token={token}\n />\n );\n}\n`;\n\n // Scaffold the spinner component if not already present\n const spinnerContent = `function Spinner({ style, ...props }: React.ComponentProps<\"svg\">) {\n return (\n <>\n <style>{\\`@keyframes pulsekit-spin { to { transform: rotate(360deg) } }\\`}</style>\n <svg\n role=\"status\"\n aria-label=\"Loading\"\n width=\"16\"\n height=\"16\"\n xmlns=\"http://www.w3.org/2000/svg\"\n fill=\"none\"\n viewBox=\"0 0 24 24\"\n style={{ animation: \"pulsekit-spin 1s linear infinite\", ...style }}\n {...props}\n >\n <circle opacity={0.25} cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" strokeWidth=\"4\" />\n <path opacity={0.75} fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z\" />\n </svg>\n </>\n );\n}\n\nexport { Spinner }\n`;\n\n const cwd = process.cwd();\n const componentsBase = appDir.includes(path.join(\"src\", \"app\"))\n ? path.join(cwd, \"src\", \"components\", \"ui\")\n : path.join(cwd, \"components\", \"ui\");\n\n const spinnerPath = path.join(componentsBase, \"spinner.tsx\");\n fs.mkdirSync(componentsBase, { recursive: true });\n if (fs.existsSync(spinnerPath)) {\n console.log(\" Skipped (already exists): components/ui/spinner.tsx\");\n } else {\n fs.writeFileSync(spinnerPath, spinnerContent, \"utf8\");\n console.log(\" Created: components/ui/spinner.tsx\");\n }\n\n // Scaffold the tracker wrapper component\n const componentsDir = appDir.includes(path.join(\"src\", \"app\"))\n ? path.join(cwd, \"src\", \"components\")\n : path.join(cwd, \"components\");\n const wrapperPath = path.join(componentsDir, \"pulse-tracker-wrapper.tsx\");\n fs.mkdirSync(componentsDir, { recursive: true });\n if (fs.existsSync(wrapperPath)) {\n console.log(\" Skipped (already exists): components/pulse-tracker-wrapper.tsx\");\n } else {\n fs.writeFileSync(wrapperPath, trackerWrapper, \"utf8\");\n console.log(\" Created: components/pulse-tracker-wrapper.tsx\");\n }\n\n const files = [\n { rel: \"api/pulse/route.ts\", content: pulseRoute },\n { rel: \"api/pulse/auth/route.ts\", content: authRoute },\n { rel: \"api/pulse/refresh-aggregates/route.ts\", content: refreshRoute },\n { rel: \"api/pulse/consolidate/route.ts\", content: consolidateRoute },\n { rel: \"admin/analytics/page.tsx\", content: dashboardPage },\n ];\n\n for (const { rel, content } of files) {\n const fullPath = path.join(appDir, rel);\n fs.mkdirSync(path.dirname(fullPath), { recursive: true });\n\n if (fs.existsSync(fullPath)) {\n console.log(` Skipped (already exists): ${rel}`);\n continue;\n }\n\n fs.writeFileSync(fullPath, content, \"utf8\");\n console.log(` Created: ${rel}`);\n }\n\n // Scaffold vercel.json with cron entries for automatic aggregation\n const vercelJsonPath = path.join(cwd, \"vercel.json\");\n if (fs.existsSync(vercelJsonPath)) {\n console.log(\" Skipped (already exists): vercel.json\");\n } else {\n const vercelConfig = {\n crons: [\n { path: \"/api/pulse/refresh-aggregates\", schedule: \"0 */6 * * *\" },\n { path: \"/api/pulse/consolidate\", schedule: \"0 3 * * *\" },\n ],\n };\n fs.writeFileSync(\n vercelJsonPath,\n JSON.stringify(vercelConfig, null, 2) + \"\\n\",\n \"utf8\",\n );\n console.log(\" Created: vercel.json (Vercel Cron for aggregation)\");\n }\n\n console.log(\"\");\n}\n","import fs from \"node:fs\";\nimport path from \"node:path\";\nimport { getAppDir } from \"./detect\";\n\nexport async function injectPulseTracker(): Promise<void> {\n console.log(\" Injecting PulseTracker into layout...\\n\");\n\n const appDir = getAppDir();\n\n const candidates = [\n path.join(appDir, \"layout.tsx\"),\n path.join(appDir, \"layout.jsx\"),\n path.join(appDir, \"layout.js\"),\n ];\n\n let foundPath: string | null = null;\n for (const candidate of candidates) {\n if (fs.existsSync(candidate)) {\n foundPath = candidate;\n break;\n }\n }\n\n if (!foundPath) {\n printManualInstructions();\n return;\n }\n\n let content = fs.readFileSync(foundPath, \"utf8\");\n\n if (content.includes(\"PulseTrackerWrapper\") || content.includes(\"PulseTracker\")) {\n console.log(\" PulseTracker already present in layout. Skipping.\\n\");\n return;\n }\n\n // Add imports after the last existing import\n const importStatements = [\n 'import { Suspense } from \"react\";',\n 'import PulseTrackerWrapper from \"@/components/pulse-tracker-wrapper\";',\n ];\n\n const importRegex = /^import\\s.+$/gm;\n let lastImportEnd = -1;\n let match: RegExpExecArray | null;\n while ((match = importRegex.exec(content)) !== null) {\n lastImportEnd = match.index + match[0].length;\n }\n\n // Only add imports that aren't already present\n const newImports = importStatements.filter((stmt) => !content.includes(stmt));\n const importBlock = newImports.join(\"\\n\");\n\n if (importBlock) {\n if (lastImportEnd === -1) {\n content = importBlock + \"\\n\" + content;\n } else {\n content =\n content.slice(0, lastImportEnd) +\n \"\\n\" +\n importBlock +\n content.slice(lastImportEnd);\n }\n }\n\n // Inject <Suspense><PulseTrackerWrapper /></Suspense> before </body>\n const bodyCloseIndex = content.lastIndexOf(\"</body>\");\n if (bodyCloseIndex === -1) {\n printManualInstructions();\n return;\n }\n\n // Detect indentation from the </body> line\n const lineStart = content.lastIndexOf(\"\\n\", bodyCloseIndex) + 1;\n const indent = content.slice(lineStart, bodyCloseIndex).match(/^\\s*/)?.[0] ?? \" \";\n const trackerJsx = [\n `${indent} <Suspense>`,\n `${indent} <PulseTrackerWrapper />`,\n `${indent} </Suspense>`,\n ].join(\"\\n\") + \"\\n\";\n\n content =\n content.slice(0, lineStart) + trackerJsx + content.slice(lineStart);\n\n fs.writeFileSync(foundPath, content, \"utf8\");\n console.log(\n ` Modified: ${path.relative(process.cwd(), foundPath)}\\n`\n );\n}\n\nfunction printManualInstructions(): void {\n console.log(\n \" Could not auto-inject PulseTracker. Add it manually to your layout:\\n\"\n );\n console.log(' import { Suspense } from \"react\";');\n console.log(' import PulseTrackerWrapper from \"@/components/pulse-tracker-wrapper\";');\n console.log(\"\");\n console.log(\" // Add inside your <body> tag:\");\n console.log(\" <Suspense>\");\n console.log(\" <PulseTrackerWrapper />\");\n console.log(\" </Suspense>\\n\");\n}\n","import fs from \"node:fs\";\nimport path from \"node:path\";\nimport { getAppDir } from \"./detect\";\n\nconst FULL_CONTENT = `import { createClient } from \"@supabase/supabase-js\";\nimport { createPulseErrorReporter } from \"@pulsekit/next\";\n\nconst supabase = createClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!\n);\n\nexport const onRequestError = createPulseErrorReporter({\n supabase,\n siteId: \"default\",\n});\n`;\n\nconst IMPORT_LINE = 'import { createPulseErrorReporter } from \"@pulsekit/next\";';\n\nconst EXPORT_BLOCK = `\nexport const onRequestError = createPulseErrorReporter({\n supabase,\n siteId: \"default\",\n});\n`;\n\nexport async function injectInstrumentation(): Promise<void> {\n console.log(\" Setting up error reporting instrumentation...\\n\");\n\n const appDir = getAppDir();\n // src/app -> src/instrumentation.ts; app/ -> instrumentation.ts\n const useSrc = appDir.includes(path.join(\"src\", \"app\"));\n const baseDir = useSrc ? path.join(process.cwd(), \"src\") : process.cwd();\n\n const candidates = [\n path.join(baseDir, \"instrumentation.ts\"),\n path.join(baseDir, \"instrumentation.js\"),\n ];\n\n let foundPath: string | null = null;\n for (const candidate of candidates) {\n if (fs.existsSync(candidate)) {\n foundPath = candidate;\n break;\n }\n }\n\n // Case 1: No file exists — create it\n if (!foundPath) {\n const targetPath = path.join(baseDir, \"instrumentation.ts\");\n fs.writeFileSync(targetPath, FULL_CONTENT, \"utf8\");\n console.log(` Created: ${path.relative(process.cwd(), targetPath)}\\n`);\n return;\n }\n\n const content = fs.readFileSync(foundPath, \"utf8\");\n\n // Case 3: Already has onRequestError — skip\n if (content.includes(\"onRequestError\")) {\n console.log(\" onRequestError already present in instrumentation. Skipping.\\n\");\n console.log(\" To add PulseKit error reporting manually, add:\\n\");\n console.log(` ${IMPORT_LINE}`);\n console.log(\"\");\n console.log(\" export const onRequestError = createPulseErrorReporter({\");\n console.log(\" supabase,\");\n console.log(' siteId: \"default\",');\n console.log(\" });\\n\");\n return;\n }\n\n // Case 2: File exists without onRequestError — append\n const importRegex = /^import\\s.+$/gm;\n let lastImportEnd = -1;\n let match: RegExpExecArray | null;\n while ((match = importRegex.exec(content)) !== null) {\n lastImportEnd = match.index + match[0].length;\n }\n\n let updated: string;\n if (lastImportEnd === -1) {\n updated = IMPORT_LINE + \"\\n\" + content + EXPORT_BLOCK;\n } else {\n updated =\n content.slice(0, lastImportEnd) +\n \"\\n\" +\n IMPORT_LINE +\n content.slice(lastImportEnd) +\n EXPORT_BLOCK;\n }\n\n fs.writeFileSync(foundPath, updated, \"utf8\");\n console.log(` Modified: ${path.relative(process.cwd(), foundPath)}\\n`);\n}\n","import fs from \"node:fs\";\nimport path from \"node:path\";\n\nconst SQL_MAP: Record<string, string> = JSON.parse(\n process.env.__EMBEDDED_SQL__!\n);\n\nexport function writeMigration(): void {\n console.log(\" Writing database migration...\\n\");\n\n const supabaseDir = path.join(process.cwd(), \"supabase\", \"migrations\");\n fs.mkdirSync(supabaseDir, { recursive: true });\n\n // Combine all SQL files into a single migration\n const files = Object.keys(SQL_MAP).sort();\n const combined = files\n .map((file) => `-- ${file}\\n${SQL_MAP[file]}`)\n .join(\"\\n\\n\");\n\n // Use a fixed timestamp so re-running doesn't create duplicates\n const filename = \"20250101000000_pulse_analytics.sql\";\n const fullPath = path.join(supabaseDir, filename);\n\n if (fs.existsSync(fullPath)) {\n console.log(` Skipped (already exists): supabase/migrations/${filename}\\n`);\n return;\n }\n\n fs.writeFileSync(fullPath, combined, \"utf8\");\n console.log(` Created: supabase/migrations/${filename}\\n`);\n}\n"],"mappings":"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","names":["import_node_fs","import_node_path","fs","path","import_node_fs","import_node_path","path","fs","import_node_fs","import_node_path","path","fs","import_node_fs","import_node_path","path","fs","import_node_fs","import_node_path","path","fs","path","fs"]}
|