@pulsekit/core 0.0.1 → 0.0.3
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/package.json +7 -15
- package/sql/001_init_pulse.sql +76 -0
- package/sql/002_aggregation_function.sql +25 -0
- package/sql/003_geo_and_timezone.sql +77 -0
- package/sql/004_web_vitals.sql +56 -0
- package/dist/cli.js +0 -47
package/package.json
CHANGED
|
@@ -1,22 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pulsekit/core",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
4
|
"main": "./dist/index.mjs",
|
|
5
5
|
"types": "./dist/index.d.mts",
|
|
6
|
-
"files": [
|
|
7
|
-
"dist"
|
|
8
|
-
],
|
|
6
|
+
"files": ["dist", "sql"],
|
|
9
7
|
"exports": {
|
|
10
8
|
".": {
|
|
11
9
|
"import": "./dist/index.mjs",
|
|
12
10
|
"types": "./dist/index.d.mts"
|
|
13
11
|
}
|
|
14
12
|
},
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
"postgres": "^3.4.0"
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsup",
|
|
15
|
+
"dev": "tsup --watch",
|
|
16
|
+
"clean": "rm -rf dist"
|
|
20
17
|
},
|
|
21
18
|
"devDependencies": {
|
|
22
19
|
"@supabase/supabase-js": "^2.49.0",
|
|
@@ -25,10 +22,5 @@
|
|
|
25
22
|
},
|
|
26
23
|
"peerDependencies": {
|
|
27
24
|
"@supabase/supabase-js": ">=2.0.0"
|
|
28
|
-
},
|
|
29
|
-
"scripts": {
|
|
30
|
-
"build": "tsup",
|
|
31
|
-
"dev": "tsup --watch",
|
|
32
|
-
"clean": "rm -rf dist"
|
|
33
25
|
}
|
|
34
|
-
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
create schema if not exists analytics;
|
|
2
|
+
|
|
3
|
+
-- Add analytics to the schemas exposed by PostgREST
|
|
4
|
+
alter role authenticator set pgrst.db_schemas = 'public, graphql_public, analytics';
|
|
5
|
+
|
|
6
|
+
-- Schema-level access
|
|
7
|
+
grant usage on schema analytics to anon, authenticated, service_role;
|
|
8
|
+
alter default privileges in schema analytics grant all on tables to anon, authenticated, service_role;
|
|
9
|
+
|
|
10
|
+
create table if not exists analytics.pulse_events (
|
|
11
|
+
id bigserial primary key,
|
|
12
|
+
site_id text not null,
|
|
13
|
+
session_id text,
|
|
14
|
+
path text not null,
|
|
15
|
+
event_type text not null,
|
|
16
|
+
meta jsonb,
|
|
17
|
+
created_at timestamptz not null default now()
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
create index if not exists idx_pulse_events_site_created_at
|
|
21
|
+
on analytics.pulse_events (site_id, created_at);
|
|
22
|
+
|
|
23
|
+
create index if not exists idx_pulse_events_site_path_created_at
|
|
24
|
+
on analytics.pulse_events (site_id, path, created_at);
|
|
25
|
+
|
|
26
|
+
alter table analytics.pulse_events enable row level security;
|
|
27
|
+
|
|
28
|
+
-- Allow the anon key (API route) to insert events
|
|
29
|
+
drop policy if exists "Allow anon insert on pulse_events" on analytics.pulse_events;
|
|
30
|
+
create policy "Allow anon insert on pulse_events"
|
|
31
|
+
on analytics.pulse_events
|
|
32
|
+
for insert
|
|
33
|
+
to anon
|
|
34
|
+
with check (true);
|
|
35
|
+
|
|
36
|
+
-- Only authenticated users (dashboard) can read events
|
|
37
|
+
drop policy if exists "Allow authenticated select on pulse_events" on analytics.pulse_events;
|
|
38
|
+
create policy "Allow authenticated select on pulse_events"
|
|
39
|
+
on analytics.pulse_events
|
|
40
|
+
for select
|
|
41
|
+
to authenticated
|
|
42
|
+
using (true);
|
|
43
|
+
|
|
44
|
+
create table if not exists analytics.pulse_aggregates (
|
|
45
|
+
date date not null,
|
|
46
|
+
site_id text not null,
|
|
47
|
+
path text not null,
|
|
48
|
+
total_views integer not null default 0,
|
|
49
|
+
unique_visitors integer not null default 0,
|
|
50
|
+
primary key (date, site_id, path)
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
-- Grant table-level access (must be after table creation)
|
|
54
|
+
grant all on all tables in schema analytics to anon, authenticated, service_role;
|
|
55
|
+
grant all on all sequences in schema analytics to anon, authenticated, service_role;
|
|
56
|
+
|
|
57
|
+
alter table analytics.pulse_aggregates enable row level security;
|
|
58
|
+
|
|
59
|
+
-- Allow reading aggregates (dashboard)
|
|
60
|
+
drop policy if exists "Allow authenticated select on pulse_aggregates" on analytics.pulse_aggregates;
|
|
61
|
+
create policy "Allow authenticated select on pulse_aggregates"
|
|
62
|
+
on analytics.pulse_aggregates
|
|
63
|
+
for select
|
|
64
|
+
to authenticated
|
|
65
|
+
using (true);
|
|
66
|
+
|
|
67
|
+
drop policy if exists "Allow anon select on pulse_aggregates" on analytics.pulse_aggregates;
|
|
68
|
+
create policy "Allow anon select on pulse_aggregates"
|
|
69
|
+
on analytics.pulse_aggregates
|
|
70
|
+
for select
|
|
71
|
+
to anon
|
|
72
|
+
using (true);
|
|
73
|
+
|
|
74
|
+
-- Reload PostgREST config and schema cache (must be last)
|
|
75
|
+
notify pgrst, 'reload config';
|
|
76
|
+
notify pgrst, 'reload schema';
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
-- Aggregation function: rolls up raw events into daily aggregates
|
|
2
|
+
create or replace function analytics.pulse_refresh_aggregates(days_back integer default 7)
|
|
3
|
+
returns void
|
|
4
|
+
language sql
|
|
5
|
+
security definer
|
|
6
|
+
as $$
|
|
7
|
+
insert into analytics.pulse_aggregates (date, site_id, path, total_views, unique_visitors)
|
|
8
|
+
select
|
|
9
|
+
date_trunc('day', created_at)::date as date,
|
|
10
|
+
site_id,
|
|
11
|
+
path,
|
|
12
|
+
count(*) as total_views,
|
|
13
|
+
count(distinct session_id) as unique_visitors
|
|
14
|
+
from analytics.pulse_events
|
|
15
|
+
where created_at >= now() - (days_back || ' days')::interval
|
|
16
|
+
group by 1, 2, 3
|
|
17
|
+
on conflict (date, site_id, path) do update
|
|
18
|
+
set
|
|
19
|
+
total_views = excluded.total_views,
|
|
20
|
+
unique_visitors = excluded.unique_visitors;
|
|
21
|
+
$$;
|
|
22
|
+
|
|
23
|
+
-- Allow all roles to execute the aggregation function
|
|
24
|
+
-- security definer ensures it runs with the owner's privileges regardless of caller
|
|
25
|
+
grant execute on function analytics.pulse_refresh_aggregates(integer) to anon, authenticated, service_role;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
-- Add geo columns to pulse_events
|
|
2
|
+
alter table analytics.pulse_events
|
|
3
|
+
add column if not exists country text,
|
|
4
|
+
add column if not exists region text,
|
|
5
|
+
add column if not exists city text,
|
|
6
|
+
add column if not exists timezone text,
|
|
7
|
+
add column if not exists latitude double precision,
|
|
8
|
+
add column if not exists longitude double precision;
|
|
9
|
+
|
|
10
|
+
-- Timezone-aware stats: queries raw events with AT TIME ZONE
|
|
11
|
+
-- so the dashboard can display data bucketed by the viewer's local day.
|
|
12
|
+
create or replace function analytics.pulse_stats_by_timezone(
|
|
13
|
+
p_site_id text,
|
|
14
|
+
p_timezone text default 'UTC',
|
|
15
|
+
p_days_back integer default 7
|
|
16
|
+
)
|
|
17
|
+
returns table (
|
|
18
|
+
date date,
|
|
19
|
+
path text,
|
|
20
|
+
total_views bigint,
|
|
21
|
+
unique_visitors bigint
|
|
22
|
+
)
|
|
23
|
+
language sql
|
|
24
|
+
security definer
|
|
25
|
+
stable
|
|
26
|
+
as $$
|
|
27
|
+
select
|
|
28
|
+
date_trunc('day', created_at at time zone p_timezone)::date as date,
|
|
29
|
+
path,
|
|
30
|
+
count(*) as total_views,
|
|
31
|
+
count(distinct session_id) as unique_visitors
|
|
32
|
+
from analytics.pulse_events
|
|
33
|
+
where site_id = p_site_id
|
|
34
|
+
and created_at >= now() - make_interval(days => p_days_back + 1)
|
|
35
|
+
group by 1, 2;
|
|
36
|
+
$$;
|
|
37
|
+
|
|
38
|
+
grant execute on function analytics.pulse_stats_by_timezone(text, text, integer)
|
|
39
|
+
to anon, authenticated, service_role;
|
|
40
|
+
|
|
41
|
+
-- Drop first so return type can change (CREATE OR REPLACE cannot alter return columns)
|
|
42
|
+
drop function if exists analytics.pulse_location_stats(text, integer);
|
|
43
|
+
|
|
44
|
+
-- Location stats: visitor counts grouped by country + city, with averaged coordinates
|
|
45
|
+
create or replace function analytics.pulse_location_stats(
|
|
46
|
+
p_site_id text,
|
|
47
|
+
p_days_back integer default 7
|
|
48
|
+
)
|
|
49
|
+
returns table (
|
|
50
|
+
country text,
|
|
51
|
+
city text,
|
|
52
|
+
latitude double precision,
|
|
53
|
+
longitude double precision,
|
|
54
|
+
total_views bigint,
|
|
55
|
+
unique_visitors bigint
|
|
56
|
+
)
|
|
57
|
+
language sql
|
|
58
|
+
security definer
|
|
59
|
+
stable
|
|
60
|
+
as $$
|
|
61
|
+
select
|
|
62
|
+
country,
|
|
63
|
+
city,
|
|
64
|
+
avg(latitude) as latitude,
|
|
65
|
+
avg(longitude) as longitude,
|
|
66
|
+
count(*) as total_views,
|
|
67
|
+
count(distinct session_id) as unique_visitors
|
|
68
|
+
from analytics.pulse_events
|
|
69
|
+
where site_id = p_site_id
|
|
70
|
+
and created_at >= now() - make_interval(days => p_days_back)
|
|
71
|
+
and country is not null
|
|
72
|
+
group by 1, 2
|
|
73
|
+
order by total_views desc;
|
|
74
|
+
$$;
|
|
75
|
+
|
|
76
|
+
grant execute on function analytics.pulse_location_stats(text, integer)
|
|
77
|
+
to anon, authenticated, service_role;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
-- 004_web_vitals.sql
|
|
2
|
+
-- Partial index + RPC for Web Vitals p75 aggregation
|
|
3
|
+
|
|
4
|
+
-- Partial index: only covers vitals events, stays small
|
|
5
|
+
CREATE INDEX IF NOT EXISTS idx_pulse_events_vitals
|
|
6
|
+
ON analytics.pulse_events (site_id, created_at)
|
|
7
|
+
WHERE event_type = 'vitals';
|
|
8
|
+
|
|
9
|
+
-- RPC: returns per-metric p75 for each page + site-wide (__overall__)
|
|
10
|
+
CREATE OR REPLACE FUNCTION analytics.pulse_vitals_stats(
|
|
11
|
+
p_site_id TEXT,
|
|
12
|
+
p_days_back INT DEFAULT 7
|
|
13
|
+
)
|
|
14
|
+
RETURNS TABLE (
|
|
15
|
+
path TEXT,
|
|
16
|
+
metric TEXT,
|
|
17
|
+
p75 DOUBLE PRECISION,
|
|
18
|
+
sample_count BIGINT
|
|
19
|
+
)
|
|
20
|
+
LANGUAGE sql SECURITY DEFINER STABLE
|
|
21
|
+
AS $$
|
|
22
|
+
WITH vitals_raw AS (
|
|
23
|
+
SELECT
|
|
24
|
+
e.path,
|
|
25
|
+
kv.key AS metric,
|
|
26
|
+
kv.value::double precision AS val
|
|
27
|
+
FROM analytics.pulse_events e,
|
|
28
|
+
LATERAL jsonb_each_text(e.meta) AS kv(key, value)
|
|
29
|
+
WHERE e.site_id = p_site_id
|
|
30
|
+
AND e.event_type = 'vitals'
|
|
31
|
+
AND e.created_at >= NOW() - (p_days_back || ' days')::interval
|
|
32
|
+
AND kv.key IN ('lcp', 'inp', 'cls', 'fcp', 'ttfb')
|
|
33
|
+
)
|
|
34
|
+
-- Per-page stats
|
|
35
|
+
SELECT
|
|
36
|
+
vr.path,
|
|
37
|
+
vr.metric,
|
|
38
|
+
percentile_cont(0.75) WITHIN GROUP (ORDER BY vr.val) AS p75,
|
|
39
|
+
count(*)::bigint AS sample_count
|
|
40
|
+
FROM vitals_raw vr
|
|
41
|
+
GROUP BY vr.path, vr.metric
|
|
42
|
+
|
|
43
|
+
UNION ALL
|
|
44
|
+
|
|
45
|
+
-- Site-wide stats
|
|
46
|
+
SELECT
|
|
47
|
+
'__overall__'::text AS path,
|
|
48
|
+
vr.metric,
|
|
49
|
+
percentile_cont(0.75) WITHIN GROUP (ORDER BY vr.val) AS p75,
|
|
50
|
+
count(*)::bigint AS sample_count
|
|
51
|
+
FROM vitals_raw vr
|
|
52
|
+
GROUP BY vr.metric;
|
|
53
|
+
$$;
|
|
54
|
+
|
|
55
|
+
GRANT EXECUTE ON FUNCTION analytics.pulse_vitals_stats(TEXT, INT)
|
|
56
|
+
TO anon, authenticated, service_role;
|
package/dist/cli.js
DELETED
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
"use strict";
|
|
3
|
-
var __create = Object.create;
|
|
4
|
-
var __defProp = Object.defineProperty;
|
|
5
|
-
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
-
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
-
var __getProtoOf = Object.getPrototypeOf;
|
|
8
|
-
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
-
var __copyProps = (to, from, except, desc) => {
|
|
10
|
-
if (from && typeof from === "object" || typeof from === "function") {
|
|
11
|
-
for (let key of __getOwnPropNames(from))
|
|
12
|
-
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
13
|
-
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
14
|
-
}
|
|
15
|
-
return to;
|
|
16
|
-
};
|
|
17
|
-
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
18
|
-
// If the importer is in node compatibility mode or this is not an ESM
|
|
19
|
-
// file that has been converted to a CommonJS file using a Babel-
|
|
20
|
-
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
21
|
-
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
22
|
-
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
23
|
-
mod
|
|
24
|
-
));
|
|
25
|
-
|
|
26
|
-
// src/cli.ts
|
|
27
|
-
var import_node_fs = __toESM(require("fs"));
|
|
28
|
-
var import_node_path = __toESM(require("path"));
|
|
29
|
-
var import_postgres = __toESM(require("postgres"));
|
|
30
|
-
async function main() {
|
|
31
|
-
const url = process.env.DATABASE_URL;
|
|
32
|
-
if (!url) throw new Error("DATABASE_URL is required");
|
|
33
|
-
const sql = (0, import_postgres.default)(url, { max: 1 });
|
|
34
|
-
const sqlDir = import_node_path.default.join(__dirname, "../sql");
|
|
35
|
-
const files = import_node_fs.default.readdirSync(sqlDir).filter((f) => f.endsWith(".sql")).sort();
|
|
36
|
-
for (const file of files) {
|
|
37
|
-
const text = import_node_fs.default.readFileSync(import_node_path.default.join(sqlDir, file), "utf8");
|
|
38
|
-
await sql.unsafe(text);
|
|
39
|
-
console.log(`Applied: ${file}`);
|
|
40
|
-
}
|
|
41
|
-
await sql.end();
|
|
42
|
-
console.log("Pulse migration complete.");
|
|
43
|
-
}
|
|
44
|
-
main().catch((err) => {
|
|
45
|
-
console.error(err);
|
|
46
|
-
process.exit(1);
|
|
47
|
-
});
|