dynalitics 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +261 -2
- package/dist/adapter-version.d.ts +3 -0
- package/dist/adapter-version.js +3 -0
- package/dist/adapter.d.ts +12 -0
- package/dist/adapter.js +7 -0
- package/dist/cli/io.d.ts +9 -0
- package/dist/cli/io.js +10 -0
- package/dist/cli/run.d.ts +4 -0
- package/dist/cli/run.js +39 -0
- package/dist/cli/sql.d.ts +5 -0
- package/dist/cli/sql.js +129 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +5 -0
- package/dist/config/schema.d.ts +64 -0
- package/dist/config/schema.js +248 -0
- package/dist/config/validate.d.ts +30 -0
- package/dist/config/validate.js +68 -0
- package/dist/create.d.ts +16 -0
- package/dist/create.js +308 -0
- package/dist/dialect/common.d.ts +54 -0
- package/dist/dialect/common.js +197 -0
- package/dist/dialect/index.d.ts +5 -0
- package/dist/dialect/index.js +8 -0
- package/dist/dialect/mssql.d.ts +3 -0
- package/dist/dialect/mssql.js +104 -0
- package/dist/dialect/mysql.d.ts +3 -0
- package/dist/dialect/mysql.js +140 -0
- package/dist/dialect/postgres.d.ts +5 -0
- package/dist/dialect/postgres.js +90 -0
- package/dist/dialect/types.d.ts +79 -0
- package/dist/dialect/types.js +2 -0
- package/dist/driver/types.d.ts +74 -0
- package/dist/driver/types.js +66 -0
- package/dist/errors.d.ts +39 -0
- package/dist/errors.js +39 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +3 -0
- package/dist/internals.d.ts +20 -0
- package/dist/internals.js +8 -0
- package/dist/logger.d.ts +10 -0
- package/dist/logger.js +24 -0
- package/dist/model/column-model.d.ts +7 -0
- package/dist/model/column-model.js +240 -0
- package/dist/model/identifiers.d.ts +19 -0
- package/dist/model/identifiers.js +62 -0
- package/dist/model/physical.d.ts +19 -0
- package/dist/model/physical.js +70 -0
- package/dist/model/types.d.ts +92 -0
- package/dist/model/types.js +9 -0
- package/dist/protocol.d.ts +222 -0
- package/dist/protocol.js +5 -0
- package/dist/queue/pipeline.d.ts +46 -0
- package/dist/queue/pipeline.js +282 -0
- package/dist/read/adapters.d.ts +7 -0
- package/dist/read/adapters.js +75 -0
- package/dist/read/buckets.d.ts +7 -0
- package/dist/read/buckets.js +39 -0
- package/dist/read/capabilities.d.ts +39 -0
- package/dist/read/capabilities.js +65 -0
- package/dist/read/handler.d.ts +48 -0
- package/dist/read/handler.js +63 -0
- package/dist/read/http.d.ts +18 -0
- package/dist/read/http.js +65 -0
- package/dist/read/index.d.ts +5 -0
- package/dist/read/index.js +3 -0
- package/dist/read/lock/authorize.d.ts +22 -0
- package/dist/read/lock/authorize.js +32 -0
- package/dist/read/lock/base-path.d.ts +11 -0
- package/dist/read/lock/base-path.js +12 -0
- package/dist/read/lock/gate.d.ts +30 -0
- package/dist/read/lock/gate.js +24 -0
- package/dist/read/lock/index.d.ts +28 -0
- package/dist/read/lock/index.js +46 -0
- package/dist/read/lock/options.d.ts +13 -0
- package/dist/read/lock/options.js +18 -0
- package/dist/read/lock/serve-html.d.ts +6 -0
- package/dist/read/lock/serve-html.js +38 -0
- package/dist/read/mask.d.ts +12 -0
- package/dist/read/mask.js +55 -0
- package/dist/read/params.d.ts +56 -0
- package/dist/read/params.js +150 -0
- package/dist/read/physical.d.ts +11 -0
- package/dist/read/physical.js +23 -0
- package/dist/read/queries.d.ts +75 -0
- package/dist/read/queries.js +316 -0
- package/dist/read/report.d.ts +21 -0
- package/dist/read/report.js +32 -0
- package/dist/read/routes.d.ts +38 -0
- package/dist/read/routes.js +261 -0
- package/dist/read/scope.d.ts +11 -0
- package/dist/read/scope.js +47 -0
- package/dist/read/search/fields.d.ts +24 -0
- package/dist/read/search/fields.js +67 -0
- package/dist/read/search/parse.d.ts +48 -0
- package/dist/read/search/parse.js +124 -0
- package/dist/read/semaphore.d.ts +6 -0
- package/dist/read/semaphore.js +34 -0
- package/dist/read/service.d.ts +42 -0
- package/dist/read/service.js +266 -0
- package/dist/read/types.d.ts +62 -0
- package/dist/read/types.js +2 -0
- package/dist/redact.d.ts +8 -0
- package/dist/redact.js +60 -0
- package/dist/report.d.ts +12 -0
- package/dist/report.js +39 -0
- package/dist/schema/generate.d.ts +46 -0
- package/dist/schema/generate.js +71 -0
- package/dist/schema/index.d.ts +5 -0
- package/dist/schema/index.js +3 -0
- package/dist/startup/compare.d.ts +22 -0
- package/dist/startup/compare.js +165 -0
- package/dist/startup/fix-sql.d.ts +15 -0
- package/dist/startup/fix-sql.js +94 -0
- package/dist/startup/privileges.d.ts +21 -0
- package/dist/startup/privileges.js +63 -0
- package/dist/startup/session-timezone.d.ts +10 -0
- package/dist/startup/session-timezone.js +19 -0
- package/dist/startup/type-families.d.ts +10 -0
- package/dist/startup/type-families.js +55 -0
- package/dist/startup/unmapped-notice.d.ts +5 -0
- package/dist/startup/unmapped-notice.js +28 -0
- package/dist/track/build-row.d.ts +50 -0
- package/dist/track/build-row.js +208 -0
- package/dist/track/custom.d.ts +31 -0
- package/dist/track/custom.js +83 -0
- package/dist/track/denied-view.d.ts +14 -0
- package/dist/track/denied-view.js +21 -0
- package/dist/track/first-visit.d.ts +19 -0
- package/dist/track/first-visit.js +70 -0
- package/dist/track/ip.d.ts +17 -0
- package/dist/track/ip.js +87 -0
- package/dist/track/limits.d.ts +7 -0
- package/dist/track/limits.js +26 -0
- package/dist/types.d.ts +136 -0
- package/dist/types.js +2 -0
- package/dist/version.d.ts +3 -0
- package/dist/version.js +3 -0
- package/package.json +71 -4
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ColumnDef, Dialect } from '../model/types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Read-side expressions that depend on the REAL column type of a table somebody else created (spec 3.9, 7.2).
|
|
4
|
+
* `expr` is an already quoted column reference or alias; the result is another SQL expression over it.
|
|
5
|
+
* Filters and ordering keep using the raw column so that existing indexes still apply.
|
|
6
|
+
*/
|
|
7
|
+
/** An expression whose value is the visit instant, so that the dialect's UTC formatting is correct for it. */
|
|
8
|
+
export declare function instantExpr(dialect: Dialect, def: ColumnDef | undefined, expr: string): string;
|
|
9
|
+
/** An expression that the dialect's `isTrue/isFalse` can test. PostgreSQL cannot say `smallint IS TRUE`. */
|
|
10
|
+
export declare function truthExpr(dialect: Dialect, def: ColumnDef | undefined, expr: string): string;
|
|
11
|
+
//# sourceMappingURL=physical.d.ts.map
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { isIntegerColumn } from '../model/physical.js';
|
|
2
|
+
/**
|
|
3
|
+
* Read-side expressions that depend on the REAL column type of a table somebody else created (spec 3.9, 7.2).
|
|
4
|
+
* `expr` is an already quoted column reference or alias; the result is another SQL expression over it.
|
|
5
|
+
* Filters and ordering keep using the raw column so that existing indexes still apply.
|
|
6
|
+
*/
|
|
7
|
+
/** An expression whose value is the visit instant, so that the dialect's UTC formatting is correct for it. */
|
|
8
|
+
export function instantExpr(dialect, def, expr) {
|
|
9
|
+
const type = def?.physicalType;
|
|
10
|
+
// A naive PostgreSQL timestamp holds the UTC wall clock: say so, so that the later `AT TIME ZONE 'UTC'` is a no-op shift.
|
|
11
|
+
if (dialect === 'postgres' && type === 'timestamp')
|
|
12
|
+
return `(${expr} AT TIME ZONE 'UTC')`;
|
|
13
|
+
// datetimeoffset keeps its own offset; CONVERT(..., 126) would print it, so move it to UTC first.
|
|
14
|
+
if (dialect === 'mssql' && type === 'datetimeoffset') {
|
|
15
|
+
return `CAST(SWITCHOFFSET(${expr}, '+00:00') AS datetime2)`;
|
|
16
|
+
}
|
|
17
|
+
return expr;
|
|
18
|
+
}
|
|
19
|
+
/** An expression that the dialect's `isTrue/isFalse` can test. PostgreSQL cannot say `smallint IS TRUE`. */
|
|
20
|
+
export function truthExpr(dialect, def, expr) {
|
|
21
|
+
return dialect === 'postgres' && isIntegerColumn(def) ? `(${expr} = 1)` : expr;
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=physical.js.map
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { CompiledQuery, SqlDialect } from '../dialect/types.js';
|
|
2
|
+
import type { ColumnModel, SqlValue } from '../model/types.js';
|
|
3
|
+
import type { Interval } from '../protocol.js';
|
|
4
|
+
import type { Dimension, NvrPlan } from './capabilities.js';
|
|
5
|
+
import type { Cursor } from './params.js';
|
|
6
|
+
import type { SearchField } from './search/fields.js';
|
|
7
|
+
import type { Clause } from './search/parse.js';
|
|
8
|
+
export interface ScopeValue {
|
|
9
|
+
readonly name: string;
|
|
10
|
+
readonly value: SqlValue;
|
|
11
|
+
}
|
|
12
|
+
export interface Env {
|
|
13
|
+
readonly dialect: SqlDialect;
|
|
14
|
+
readonly model: ColumnModel;
|
|
15
|
+
/** Row scope (spec 10.1, F-08): `AND <column> = <bound value>` on EVERY reference to the table, probes included. */
|
|
16
|
+
readonly scope: readonly ScopeValue[];
|
|
17
|
+
}
|
|
18
|
+
/** A parsed explorer query (spec 18.2) and the fields bare terms search. Values are bound, never inlined. */
|
|
19
|
+
export interface SearchFilter {
|
|
20
|
+
readonly clauses: readonly Clause[];
|
|
21
|
+
readonly searchable: readonly SearchField[];
|
|
22
|
+
}
|
|
23
|
+
export interface Range {
|
|
24
|
+
readonly from: Date;
|
|
25
|
+
readonly to: Date;
|
|
26
|
+
/** Explorer only: restricts every statement built for this range to the matching rows. */
|
|
27
|
+
readonly filter?: SearchFilter | undefined;
|
|
28
|
+
}
|
|
29
|
+
export declare function visitsSeries(env: Env, r: Range & {
|
|
30
|
+
interval: Interval;
|
|
31
|
+
}): CompiledQuery;
|
|
32
|
+
export declare const BREAKDOWN_TOP = 10;
|
|
33
|
+
export declare function breakdownTop(env: Env, dim: Dimension, r: Range, top?: number): CompiledQuery;
|
|
34
|
+
/** Rows in range whose dimension value is NULL (explorer facets, spec 18.3). */
|
|
35
|
+
export declare function nullCount(env: Env, dim: Dimension, r: Range): CompiledQuery;
|
|
36
|
+
export declare function breakdownSeries(env: Env, dim: Dimension, r: Range & {
|
|
37
|
+
interval: Interval;
|
|
38
|
+
}, values: readonly string[]): CompiledQuery;
|
|
39
|
+
export declare function topTotal(env: Env, r: Range): CompiledQuery;
|
|
40
|
+
export declare function topPages(env: Env, r: Range, limit: number): CompiledQuery;
|
|
41
|
+
export declare function consentSeries(env: Env, r: Range & {
|
|
42
|
+
interval: Interval;
|
|
43
|
+
}): CompiledQuery;
|
|
44
|
+
type ComputedPlan = Extract<NvrPlan, {
|
|
45
|
+
source: 'computed';
|
|
46
|
+
}>;
|
|
47
|
+
type ColumnPlan = Extract<NvrPlan, {
|
|
48
|
+
source: 'column';
|
|
49
|
+
}>;
|
|
50
|
+
/** Q1 of spec 10.11: totals and identified visits per bucket. */
|
|
51
|
+
export declare function nvrTotals(env: Env, r: Range & {
|
|
52
|
+
interval: Interval;
|
|
53
|
+
}, plan: ComputedPlan): CompiledQuery;
|
|
54
|
+
/**
|
|
55
|
+
* Q2/Q3 of spec 10.11: identities whose first row in the range has no earlier row anywhere. One index probe per
|
|
56
|
+
* identity. The probe carries the scope too, so rows of another tenant never make a visitor "returning".
|
|
57
|
+
*/
|
|
58
|
+
export declare function nvrNew(env: Env, r: Range & {
|
|
59
|
+
interval: Interval;
|
|
60
|
+
}, plan: ComputedPlan, kind: 'u' | 'i'): CompiledQuery;
|
|
61
|
+
/** Opt-in source: the stored write-time column. */
|
|
62
|
+
export declare function nvrColumn(env: Env, r: Range & {
|
|
63
|
+
interval: Interval;
|
|
64
|
+
}, plan: ColumnPlan): CompiledQuery;
|
|
65
|
+
/** Promoted custom-field columns, in a stable order shared with the row mapper. */
|
|
66
|
+
export declare function promotedColumns(env: Env): {
|
|
67
|
+
key: string;
|
|
68
|
+
name: string;
|
|
69
|
+
}[];
|
|
70
|
+
export declare function recentVisits(env: Env, r: Range & {
|
|
71
|
+
limit: number;
|
|
72
|
+
cursor: Cursor | undefined;
|
|
73
|
+
}, plan: NvrPlan): CompiledQuery;
|
|
74
|
+
export {};
|
|
75
|
+
//# sourceMappingURL=queries.d.ts.map
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { instantExpr, truthExpr } from './physical.js';
|
|
2
|
+
/** Collects bound parameters in order and hands out the dialect's placeholder for each. */
|
|
3
|
+
class Params {
|
|
4
|
+
dialect;
|
|
5
|
+
values = [];
|
|
6
|
+
constructor(dialect) {
|
|
7
|
+
this.dialect = dialect;
|
|
8
|
+
}
|
|
9
|
+
add(v) {
|
|
10
|
+
this.values.push(v);
|
|
11
|
+
return this.dialect.placeholder(this.values.length);
|
|
12
|
+
}
|
|
13
|
+
date(d) {
|
|
14
|
+
return this.add(this.dialect.timestampParam(d));
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function names(env) {
|
|
18
|
+
const def = (role) => env.model.columns.find((x) => x.role === role);
|
|
19
|
+
const dialect = env.dialect.name;
|
|
20
|
+
const q = (role) => {
|
|
21
|
+
const c = env.model.columns.find((x) => x.role === role);
|
|
22
|
+
return c === undefined ? undefined : env.dialect.quoteIdent(c.name);
|
|
23
|
+
};
|
|
24
|
+
const must = (role) => {
|
|
25
|
+
const c = q(role);
|
|
26
|
+
if (c === undefined)
|
|
27
|
+
throw new Error(`required column for role ${role} is missing`);
|
|
28
|
+
return c;
|
|
29
|
+
};
|
|
30
|
+
return {
|
|
31
|
+
table: env.dialect.qualifiedTable(env.model.table),
|
|
32
|
+
ts: must('timestamp'),
|
|
33
|
+
consent: must('consent'),
|
|
34
|
+
page: must('pageVisited'),
|
|
35
|
+
id: q('id'),
|
|
36
|
+
ip: q('ip'),
|
|
37
|
+
user: q('userId'),
|
|
38
|
+
username: q('username'),
|
|
39
|
+
email: q('email'),
|
|
40
|
+
custom: q('custom'),
|
|
41
|
+
fv: q('isFirstVisit'),
|
|
42
|
+
/** The visit instant for display and bucketing (the raw column is still used for filters and ordering). */
|
|
43
|
+
inst: (expr) => instantExpr(dialect, def('timestamp'), expr),
|
|
44
|
+
/** A boolean-role column as something `isTrue/isFalse` accept (integer consent columns, PostgreSQL). */
|
|
45
|
+
truth: (role, expr) => truthExpr(dialect, def(role), expr),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const finish = (p, sql) => ({ sql, params: p.values });
|
|
49
|
+
/** ` AND [alias.]col = ?` for every configured scope column, values bound. Empty string when unscoped. */
|
|
50
|
+
function scoped(env, p, alias = '') {
|
|
51
|
+
return env.scope
|
|
52
|
+
.map((s) => ` AND ${alias}${env.dialect.quoteIdent(s.name)} = ${p.add(s.value)}`)
|
|
53
|
+
.join('');
|
|
54
|
+
}
|
|
55
|
+
/** `ts >= from AND ts < to` plus scope and the explorer filter, for the table reference named by `alias` ('' = unaliased). */
|
|
56
|
+
function inRange(env, p, ts, r, alias = '') {
|
|
57
|
+
// Parameters are added in text order: MySQL's `?` markers are positional (spec 5.1).
|
|
58
|
+
const range = `${alias}${ts} >= ${p.date(r.from)} AND ${alias}${ts} < ${p.date(r.to)}${scoped(env, p, alias)}`;
|
|
59
|
+
return r.filter === undefined ? range : `${range}${filterSql(env, p, r.filter, alias)}`;
|
|
60
|
+
}
|
|
61
|
+
// --- explorer filter (spec 18.2) ---------------------------------------------------------------------------------
|
|
62
|
+
/** LIKE metacharacters of all three dialects ('[' is one on SQL Server), escaped with '!' (F-39 forbids a backslash). */
|
|
63
|
+
const likeEscape = (v) => v.replace(/[!%_[]/g, (c) => `!${c}`);
|
|
64
|
+
function likePattern(m) {
|
|
65
|
+
const v = likeEscape(m.value);
|
|
66
|
+
return m.kind === 'prefix' ? `${v}%` : m.kind === 'suffix' ? `%${v}` : `%${v}%`;
|
|
67
|
+
}
|
|
68
|
+
/** The field as text (what LOWER/LIKE and facets work on). */
|
|
69
|
+
function fieldText(env, f, alias) {
|
|
70
|
+
return dimensionExpr(env, f.dim, alias);
|
|
71
|
+
}
|
|
72
|
+
function textPredicate(env, p, f, m, alias) {
|
|
73
|
+
const expr = fieldText(env, f, alias);
|
|
74
|
+
if (m.kind === 'exists')
|
|
75
|
+
return `${expr} IS NOT NULL`;
|
|
76
|
+
if (m.kind === 'exact')
|
|
77
|
+
return `LOWER(${expr}) = ${p.add(m.value)}`;
|
|
78
|
+
return `LOWER(${expr}) LIKE ${p.add(likePattern(m))} ESCAPE '!'`;
|
|
79
|
+
}
|
|
80
|
+
function booleanPredicate(env, f, value, alias) {
|
|
81
|
+
const read = env.dialect.read;
|
|
82
|
+
const d = f.dim;
|
|
83
|
+
const col = (c) => `${alias}${env.dialect.quoteIdent(c.name)}`;
|
|
84
|
+
const test = (expr) => (value ? read.isTrue(expr) : read.isFalse(expr));
|
|
85
|
+
switch (d.kind) {
|
|
86
|
+
case 'consent': {
|
|
87
|
+
const c = env.model.columns.find((x) => x.role === 'consent');
|
|
88
|
+
if (c === undefined)
|
|
89
|
+
throw new Error('consent column is not mapped');
|
|
90
|
+
// NULL consent counts as denied (3.2), so `consent:false` is "not true".
|
|
91
|
+
const truth = truthExpr(env.dialect.name, c, col(c));
|
|
92
|
+
return value ? read.isTrue(truth) : `NOT (${caseOf(read.isTrue(truth))} = 1)`;
|
|
93
|
+
}
|
|
94
|
+
case 'isFirstVisit':
|
|
95
|
+
case 'promoted': {
|
|
96
|
+
if (d.column === undefined)
|
|
97
|
+
throw new Error('boolean field without a column');
|
|
98
|
+
return test(truthExpr(env.dialect.name, d.column, col(d.column)));
|
|
99
|
+
}
|
|
100
|
+
case 'json':
|
|
101
|
+
case 'page':
|
|
102
|
+
return `${fieldText(env, f, alias)} = '${value ? 'true' : 'false'}'`;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const caseOf = (pred) => `CASE WHEN ${pred} THEN 1 ELSE 0 END`;
|
|
106
|
+
function numberPredicate(env, p, f, c, alias) {
|
|
107
|
+
const d = f.dim;
|
|
108
|
+
let expr;
|
|
109
|
+
if (d.kind === 'promoted' && d.column !== undefined)
|
|
110
|
+
expr = `${alias}${env.dialect.quoteIdent(d.column.name)}`;
|
|
111
|
+
else if (d.kind === 'json' && d.column !== undefined)
|
|
112
|
+
expr = env.dialect.read.jsonNumber(`${alias}${env.dialect.quoteIdent(d.column.name)}`, d.key);
|
|
113
|
+
else
|
|
114
|
+
throw new Error('number field without a column');
|
|
115
|
+
return `${expr} ${c.op} ${p.add(c.value)}`;
|
|
116
|
+
}
|
|
117
|
+
function clauseSql(env, p, f, c, alias) {
|
|
118
|
+
let pred;
|
|
119
|
+
switch (c.type) {
|
|
120
|
+
case 'term': {
|
|
121
|
+
const pattern = `%${likeEscape(c.value)}%`;
|
|
122
|
+
const any = f.searchable.map((s) => `LOWER(${fieldText(env, s, alias)}) LIKE ${p.add(pattern)} ESCAPE '!'`);
|
|
123
|
+
pred = any.length === 0 ? '1 = 0' : any.join(' OR ');
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
case 'text':
|
|
127
|
+
pred = textPredicate(env, p, c.field, c.match, alias);
|
|
128
|
+
break;
|
|
129
|
+
case 'boolean':
|
|
130
|
+
pred = booleanPredicate(env, c.field, c.value, alias);
|
|
131
|
+
break;
|
|
132
|
+
case 'number':
|
|
133
|
+
pred = numberPredicate(env, p, c.field, c, alias);
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
// NULL-safe negation: rows where the field is missing match `-x` (spec 18.2).
|
|
137
|
+
return c.negate ? `${caseOf(`(${pred})`)} = 0` : `(${pred})`;
|
|
138
|
+
}
|
|
139
|
+
/** ` AND (...)` per clause, or '' for an empty query. */
|
|
140
|
+
function filterSql(env, p, f, alias) {
|
|
141
|
+
return f.clauses.map((c) => ` AND ${clauseSql(env, p, f, c, alias)}`).join('');
|
|
142
|
+
}
|
|
143
|
+
export function visitsSeries(env, r) {
|
|
144
|
+
const n = names(env);
|
|
145
|
+
const p = new Params(env.dialect);
|
|
146
|
+
const bucket = env.dialect.read.bucket(n.inst(n.ts), r.interval);
|
|
147
|
+
return finish(p, `SELECT bucket, ${env.dialect.read.count} AS visits FROM (SELECT ${bucket} AS bucket FROM ${n.table} WHERE ${inRange(env, p, n.ts, r)}) x GROUP BY bucket ORDER BY bucket`);
|
|
148
|
+
}
|
|
149
|
+
/** A dimension as text. `alias` ('' or 'r.') qualifies every column reference. */
|
|
150
|
+
function dimensionExpr(env, dim, alias = '') {
|
|
151
|
+
const n = names(env);
|
|
152
|
+
const read = env.dialect.read;
|
|
153
|
+
switch (dim.kind) {
|
|
154
|
+
case 'page':
|
|
155
|
+
return `${alias}${n.page}`;
|
|
156
|
+
case 'consent':
|
|
157
|
+
return `CASE WHEN ${read.isTrue(n.truth('consent', `${alias}${n.consent}`))} THEN 'true' ELSE 'false' END`;
|
|
158
|
+
case 'isFirstVisit': {
|
|
159
|
+
if (n.fv === undefined)
|
|
160
|
+
throw new Error('isFirstVisit column is not mapped');
|
|
161
|
+
const fv = `${alias}${n.fv}`;
|
|
162
|
+
return `CASE WHEN ${fv} IS NULL THEN NULL WHEN ${read.isTrue(n.truth('isFirstVisit', fv))} THEN 'true' ELSE 'false' END`;
|
|
163
|
+
}
|
|
164
|
+
case 'json':
|
|
165
|
+
if (n.custom === undefined)
|
|
166
|
+
throw new Error('custom column is not mapped');
|
|
167
|
+
return read.jsonKey(`${alias}${n.custom}`, dim.key);
|
|
168
|
+
case 'promoted': {
|
|
169
|
+
if (dim.column === undefined || dim.fieldType === undefined) {
|
|
170
|
+
throw new Error('promoted dimension without a column');
|
|
171
|
+
}
|
|
172
|
+
const quoted = `${alias}${env.dialect.quoteIdent(dim.column.name)}`;
|
|
173
|
+
return read.castText(dim.fieldType === 'boolean' ? truthExpr(env.dialect.name, dim.column, quoted) : quoted, dim.fieldType);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
export const BREAKDOWN_TOP = 10;
|
|
178
|
+
export function breakdownTop(env, dim, r, top = BREAKDOWN_TOP) {
|
|
179
|
+
const n = names(env);
|
|
180
|
+
const p = new Params(env.dialect);
|
|
181
|
+
const read = env.dialect.read;
|
|
182
|
+
return finish(p, `SELECT ${read.top(top)}v, ${read.count} AS n FROM (SELECT ${dimensionExpr(env, dim)} AS v FROM ${n.table} WHERE ${inRange(env, p, n.ts, r)}) x WHERE v IS NOT NULL GROUP BY v ORDER BY n DESC, v${read.limit(top)}`);
|
|
183
|
+
}
|
|
184
|
+
/** Rows in range whose dimension value is NULL (explorer facets, spec 18.3). */
|
|
185
|
+
export function nullCount(env, dim, r) {
|
|
186
|
+
const n = names(env);
|
|
187
|
+
const p = new Params(env.dialect);
|
|
188
|
+
return finish(p, `SELECT ${env.dialect.read.count} AS n FROM (SELECT ${dimensionExpr(env, dim)} AS v FROM ${n.table} WHERE ${inRange(env, p, n.ts, r)}) x WHERE v IS NULL`);
|
|
189
|
+
}
|
|
190
|
+
export function breakdownSeries(env, dim, r, values) {
|
|
191
|
+
const n = names(env);
|
|
192
|
+
const p = new Params(env.dialect);
|
|
193
|
+
const read = env.dialect.read;
|
|
194
|
+
const inner = `SELECT ${read.bucket(n.inst(n.ts), r.interval)} AS bucket, ${dimensionExpr(env, dim)} AS v FROM ${n.table} WHERE ${inRange(env, p, n.ts, r)}`;
|
|
195
|
+
const only = values.length === 0
|
|
196
|
+
? 'v IS NULL'
|
|
197
|
+
: `v IS NULL OR v IN (${values.map((v) => p.add(v)).join(', ')})`;
|
|
198
|
+
return finish(p, `SELECT bucket, v, ${read.count} AS n FROM (${inner}) x WHERE ${only} GROUP BY bucket, v`);
|
|
199
|
+
}
|
|
200
|
+
export function topTotal(env, r) {
|
|
201
|
+
const n = names(env);
|
|
202
|
+
const p = new Params(env.dialect);
|
|
203
|
+
return finish(p, `SELECT ${env.dialect.read.count} AS n FROM ${n.table} WHERE ${inRange(env, p, n.ts, r)}`);
|
|
204
|
+
}
|
|
205
|
+
export function topPages(env, r, limit) {
|
|
206
|
+
const n = names(env);
|
|
207
|
+
const p = new Params(env.dialect);
|
|
208
|
+
const read = env.dialect.read;
|
|
209
|
+
return finish(p, `SELECT ${read.top(limit)}page, ${read.count} AS n FROM (SELECT ${n.page} AS page FROM ${n.table} WHERE ${inRange(env, p, n.ts, r)}) x GROUP BY page ORDER BY n DESC, page${read.limit(limit)}`);
|
|
210
|
+
}
|
|
211
|
+
export function consentSeries(env, r) {
|
|
212
|
+
const n = names(env);
|
|
213
|
+
const p = new Params(env.dialect);
|
|
214
|
+
const read = env.dialect.read;
|
|
215
|
+
return finish(p, `SELECT bucket, SUM(g) AS granted, ${read.count} AS total FROM (SELECT ${read.bucket(n.inst(n.ts), r.interval)} AS bucket, CASE WHEN ${read.isTrue(n.truth('consent', n.consent))} THEN 1 ELSE 0 END AS g FROM ${n.table} WHERE ${inRange(env, p, n.ts, r)}) x GROUP BY bucket`);
|
|
216
|
+
}
|
|
217
|
+
/** Q1 of spec 10.11: totals and identified visits per bucket. */
|
|
218
|
+
export function nvrTotals(env, r, plan) {
|
|
219
|
+
const n = names(env);
|
|
220
|
+
const p = new Params(env.dialect);
|
|
221
|
+
const read = env.dialect.read;
|
|
222
|
+
const identified = [
|
|
223
|
+
plan.user === undefined ? undefined : `${env.dialect.quoteIdent(plan.user.name)} IS NOT NULL`,
|
|
224
|
+
plan.ip === undefined ? undefined : `${env.dialect.quoteIdent(plan.ip.name)} IS NOT NULL`,
|
|
225
|
+
]
|
|
226
|
+
.filter((x) => x !== undefined)
|
|
227
|
+
.join(' OR ');
|
|
228
|
+
return finish(p, `SELECT bucket, ${read.count} AS total, SUM(identified) AS identified FROM (SELECT ${read.bucket(n.inst(n.ts), r.interval)} AS bucket, CASE WHEN ${read.isTrue(n.truth('consent', n.consent))} AND (${identified}) THEN 1 ELSE 0 END AS identified FROM ${n.table} WHERE ${inRange(env, p, n.ts, r)}) x GROUP BY bucket`);
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Q2/Q3 of spec 10.11: identities whose first row in the range has no earlier row anywhere. One index probe per
|
|
232
|
+
* identity. The probe carries the scope too, so rows of another tenant never make a visitor "returning".
|
|
233
|
+
*/
|
|
234
|
+
export function nvrNew(env, r, plan, kind) {
|
|
235
|
+
const n = names(env);
|
|
236
|
+
const p = new Params(env.dialect);
|
|
237
|
+
const read = env.dialect.read;
|
|
238
|
+
const col = kind === 'u' ? plan.user : plan.ip;
|
|
239
|
+
if (col === undefined)
|
|
240
|
+
throw new Error(`no ${kind === 'u' ? 'user id' : 'ip'} column for new-vs-returning`);
|
|
241
|
+
const ident = env.dialect.quoteIdent(col.name);
|
|
242
|
+
const userNull = kind === 'i' && n.user !== undefined ? `${n.user} IS NULL AND ` : '';
|
|
243
|
+
const inner = `SELECT ${ident} AS ident, MIN(${n.ts}) AS first_ts FROM ${n.table} WHERE ${inRange(env, p, n.ts, r)} AND ${read.isTrue(n.truth('consent', n.consent))} AND ${userNull}${ident} IS NOT NULL GROUP BY ${ident}`;
|
|
244
|
+
const probe = `SELECT 1 FROM ${n.table} p WHERE p.${ident} = m.ident AND p.${n.ts} < ${p.date(r.from)}${scoped(env, p, 'p.')}`;
|
|
245
|
+
return finish(p, `SELECT bucket, ${read.count} AS n FROM (SELECT ${read.bucket(n.inst('m.first_ts'), r.interval)} AS bucket FROM (${inner}) m WHERE NOT EXISTS (${probe})) y GROUP BY bucket`);
|
|
246
|
+
}
|
|
247
|
+
/** Opt-in source: the stored write-time column. */
|
|
248
|
+
export function nvrColumn(env, r, plan) {
|
|
249
|
+
const n = names(env);
|
|
250
|
+
const p = new Params(env.dialect);
|
|
251
|
+
const read = env.dialect.read;
|
|
252
|
+
const fv = env.dialect.quoteIdent(plan.column.name);
|
|
253
|
+
return finish(p, `SELECT bucket, ${read.count} AS total, SUM(nw) AS new_count, SUM(rt) AS returning_count FROM (SELECT ${read.bucket(n.inst(n.ts), r.interval)} AS bucket, CASE WHEN ${read.isTrue(n.truth('isFirstVisit', fv))} THEN 1 ELSE 0 END AS nw, CASE WHEN ${read.isFalse(n.truth('isFirstVisit', fv))} THEN 1 ELSE 0 END AS rt FROM ${n.table} WHERE ${inRange(env, p, n.ts, r)}) x GROUP BY bucket`);
|
|
254
|
+
}
|
|
255
|
+
/** Per-row "is this the identity's first row?" for the page of recent visits (spec 10.11, last paragraph). */
|
|
256
|
+
function firstFlag(env, p, plan) {
|
|
257
|
+
const n = names(env);
|
|
258
|
+
const read = env.dialect.read;
|
|
259
|
+
if (!plan.available)
|
|
260
|
+
return 'NULL';
|
|
261
|
+
if (plan.source === 'column') {
|
|
262
|
+
const fv = `r.${env.dialect.quoteIdent(plan.column.name)}`;
|
|
263
|
+
return `CASE WHEN ${fv} IS NULL THEN NULL WHEN ${read.isTrue(n.truth('isFirstVisit', fv))} THEN 1 ELSE 0 END`;
|
|
264
|
+
}
|
|
265
|
+
const earlier = (col) => {
|
|
266
|
+
const tie = n.id === undefined ? '' : ` OR (p.${n.ts} = r.${n.ts} AND p.${n.id} < r.${n.id})`;
|
|
267
|
+
return `CASE WHEN NOT EXISTS (SELECT 1 FROM ${n.table} p WHERE p.${col} = r.${col} AND (p.${n.ts} < r.${n.ts}${tie})${scoped(env, p, 'p.')}) THEN 1 ELSE 0 END`;
|
|
268
|
+
};
|
|
269
|
+
const branches = [];
|
|
270
|
+
const consent = read.isTrue(n.truth('consent', `r.${n.consent}`));
|
|
271
|
+
if (plan.user !== undefined) {
|
|
272
|
+
const u = env.dialect.quoteIdent(plan.user.name);
|
|
273
|
+
branches.push(`WHEN ${consent} AND r.${u} IS NOT NULL THEN ${earlier(u)}`);
|
|
274
|
+
}
|
|
275
|
+
if (plan.ip !== undefined) {
|
|
276
|
+
const ip = env.dialect.quoteIdent(plan.ip.name);
|
|
277
|
+
const noUser = plan.user === undefined ? '' : ` AND r.${env.dialect.quoteIdent(plan.user.name)} IS NULL`;
|
|
278
|
+
branches.push(`WHEN ${consent}${noUser} AND r.${ip} IS NOT NULL THEN ${earlier(ip)}`);
|
|
279
|
+
}
|
|
280
|
+
return `CASE ${branches.join(' ')} ELSE NULL END`;
|
|
281
|
+
}
|
|
282
|
+
/** Promoted custom-field columns, in a stable order shared with the row mapper. */
|
|
283
|
+
export function promotedColumns(env) {
|
|
284
|
+
return [...env.model.customFields.values()]
|
|
285
|
+
.filter((f) => f.column !== undefined)
|
|
286
|
+
.map((f) => ({ key: f.key, name: f.column?.name ?? '' }));
|
|
287
|
+
}
|
|
288
|
+
export function recentVisits(env, r, plan) {
|
|
289
|
+
const n = names(env);
|
|
290
|
+
const p = new Params(env.dialect);
|
|
291
|
+
const read = env.dialect.read;
|
|
292
|
+
const col = (c, alias) => c === undefined ? `NULL AS ${alias}` : `r.${c} AS ${alias}`;
|
|
293
|
+
// The select list is built first: its probes' parameters come before the WHERE clause's, as in the text.
|
|
294
|
+
const select = [
|
|
295
|
+
col(n.id, 'rid'),
|
|
296
|
+
`${read.tsText(n.inst(`r.${n.ts}`))} AS ts_text`,
|
|
297
|
+
`r.${n.page} AS page`,
|
|
298
|
+
`CASE WHEN ${read.isTrue(n.truth('consent', `r.${n.consent}`))} THEN 1 ELSE 0 END AS consent_flag`,
|
|
299
|
+
col(n.ip, 'ip_v'),
|
|
300
|
+
col(n.user, 'user_v'),
|
|
301
|
+
col(n.username, 'name_v'),
|
|
302
|
+
col(n.email, 'email_v'),
|
|
303
|
+
col(n.custom, 'custom_v'),
|
|
304
|
+
...promotedColumns(env).map((c, i) => col(env.dialect.quoteIdent(c.name), `pf${i}`)),
|
|
305
|
+
`${firstFlag(env, p, plan)} AS first_flag`,
|
|
306
|
+
].join(', ');
|
|
307
|
+
const top = read.top(r.limit + 1);
|
|
308
|
+
let where = inRange(env, p, n.ts, r, 'r.');
|
|
309
|
+
if (r.cursor !== undefined && n.id !== undefined) {
|
|
310
|
+
const at = p.date(r.cursor.ts);
|
|
311
|
+
where += ` AND (r.${n.ts} < ${at} OR (r.${n.ts} = ${p.date(r.cursor.ts)} AND r.${n.id} < ${p.add(r.cursor.id)}))`;
|
|
312
|
+
}
|
|
313
|
+
const order = n.id === undefined ? `r.${n.ts} DESC` : `r.${n.ts} DESC, r.${n.id} DESC`;
|
|
314
|
+
return finish(p, `SELECT ${top}${select} FROM ${n.table} r WHERE ${where} ORDER BY ${order}${read.limit(r.limit + 1)}`);
|
|
315
|
+
}
|
|
316
|
+
//# sourceMappingURL=queries.js.map
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { SafeCause } from '../errors.js';
|
|
2
|
+
import type { Internals } from '../internals.js';
|
|
3
|
+
import type { ErrorCode } from '../protocol.js';
|
|
4
|
+
/**
|
|
5
|
+
* What the handler reports to `onError` (F-40): the route, the HTTP status it answered with, a code, a fixed message and
|
|
6
|
+
* a sanitised cause. Never the request, its headers or the query string, so logging it cannot leak cookies or tokens.
|
|
7
|
+
*/
|
|
8
|
+
export interface ReadErrorInfo {
|
|
9
|
+
route: string;
|
|
10
|
+
status: number;
|
|
11
|
+
code: ErrorCode | 'authorize_invalid_result';
|
|
12
|
+
message: string;
|
|
13
|
+
cause?: SafeCause;
|
|
14
|
+
}
|
|
15
|
+
export type Report = (route: string, code: ReadErrorInfo['code'], message: string, cause?: SafeCause) => void;
|
|
16
|
+
/**
|
|
17
|
+
* Shared by both layers. The route is only echoed for known routes, so a hostile path can never reach the log. The
|
|
18
|
+
* host's `onError` is called inside a try/catch: a throwing callback must never break the handler.
|
|
19
|
+
*/
|
|
20
|
+
export declare function createReporter(onError: ((info: ReadErrorInfo) => void) | undefined, internals: Pick<Internals, 'report'>, knownRoutes: ReadonlySet<string>): Report;
|
|
21
|
+
//# sourceMappingURL=report.d.ts.map
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { ERROR_STATUS } from './http.js';
|
|
2
|
+
/**
|
|
3
|
+
* Shared by both layers. The route is only echoed for known routes, so a hostile path can never reach the log. The
|
|
4
|
+
* host's `onError` is called inside a try/catch: a throwing callback must never break the handler.
|
|
5
|
+
*/
|
|
6
|
+
export function createReporter(onError, internals, knownRoutes) {
|
|
7
|
+
return (route, code, message, cause) => {
|
|
8
|
+
const status = code === 'authorize_invalid_result' ? 403 : ERROR_STATUS[code];
|
|
9
|
+
const info = {
|
|
10
|
+
route: knownRoutes.has(route) ? route : '(other)',
|
|
11
|
+
status,
|
|
12
|
+
code,
|
|
13
|
+
message,
|
|
14
|
+
...(cause === undefined ? {} : { cause }),
|
|
15
|
+
};
|
|
16
|
+
try {
|
|
17
|
+
if (onError !== undefined)
|
|
18
|
+
onError(info);
|
|
19
|
+
else {
|
|
20
|
+
internals.report({
|
|
21
|
+
code: code === 'authorize_invalid_result' ? 'authorize_invalid_result' : 'internal',
|
|
22
|
+
message: `${info.route}: ${message}`,
|
|
23
|
+
...(cause === undefined ? {} : { cause }),
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// the host's callback must never break the handler
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
//# sourceMappingURL=report.js.map
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Row } from '../dialect/types.js';
|
|
2
|
+
import type { ConsentRateData, Interval, NewVsReturningData, RecentVisitsData, SearchData, SearchFieldsData, SearchHistogramData, TopPagesData, Unavailable, VisitsOverTimeData } from '../protocol.js';
|
|
3
|
+
import type { Dimension, NvrPlan } from './capabilities.js';
|
|
4
|
+
import { type ParsedParams } from './params.js';
|
|
5
|
+
import * as q from './queries.js';
|
|
6
|
+
import { type SearchField } from './search/fields.js';
|
|
7
|
+
export interface RouteCtx {
|
|
8
|
+
readonly env: q.Env;
|
|
9
|
+
readonly exec: (query: ReturnType<typeof q.visitsSeries>) => Promise<readonly Row[]>;
|
|
10
|
+
readonly params: ParsedParams;
|
|
11
|
+
readonly plan: NvrPlan;
|
|
12
|
+
readonly dimensions: ReadonlyMap<string, Dimension>;
|
|
13
|
+
readonly reveal: boolean;
|
|
14
|
+
/** Mask userId unless revealed (pii.maskUserId, default true). */
|
|
15
|
+
readonly maskUserId: boolean;
|
|
16
|
+
/** Reports an internal inconsistency (never the request, never visitor data). */
|
|
17
|
+
readonly report: (message: string) => void;
|
|
18
|
+
/** Explorer allow-list (spec 18.1). */
|
|
19
|
+
readonly searchFields: ReadonlyMap<string, SearchField>;
|
|
20
|
+
/** Explorer routes only: the parsed `q`. Undefined for every other route. */
|
|
21
|
+
readonly filter?: q.SearchFilter | undefined;
|
|
22
|
+
}
|
|
23
|
+
export type RouteResult<T> = {
|
|
24
|
+
readonly data: T;
|
|
25
|
+
} | {
|
|
26
|
+
readonly unavailable: Unavailable;
|
|
27
|
+
};
|
|
28
|
+
export declare function visitsOverTime(c: RouteCtx): Promise<RouteResult<VisitsOverTimeData>>;
|
|
29
|
+
export declare function topPages(c: RouteCtx): Promise<RouteResult<TopPagesData>>;
|
|
30
|
+
export declare function consentRate(c: RouteCtx): Promise<RouteResult<ConsentRateData>>;
|
|
31
|
+
export declare function newVsReturning(c: RouteCtx): Promise<RouteResult<NewVsReturningData>>;
|
|
32
|
+
export declare function recentVisits(c: RouteCtx): Promise<RouteResult<RecentVisitsData>>;
|
|
33
|
+
export declare const FACET_TOP = 5;
|
|
34
|
+
export declare function search(c: RouteCtx): Promise<RouteResult<SearchData>>;
|
|
35
|
+
export declare function searchHistogram(c: RouteCtx): Promise<RouteResult<SearchHistogramData>>;
|
|
36
|
+
export declare function searchFields(c: RouteCtx): Promise<RouteResult<SearchFieldsData>>;
|
|
37
|
+
export type { Interval };
|
|
38
|
+
//# sourceMappingURL=routes.d.ts.map
|