@arivie/db-postgres 0.1.2 → 2.0.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/dist/index.js +13 -550
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -1,236 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import postgres from 'postgres';
|
|
3
|
-
import { ArivieBoundaryError } from '@arivie/core/types';
|
|
4
|
-
export { ArivieBoundaryError } from '@arivie/core/types';
|
|
5
|
-
|
|
6
|
-
// src/adapter.ts
|
|
7
|
-
var ToolError = class extends Error {
|
|
8
|
-
constructor(kind, message) {
|
|
9
|
-
super(message ?? kind);
|
|
10
|
-
this.kind = kind;
|
|
11
|
-
this.name = "ToolError";
|
|
12
|
-
}
|
|
13
|
-
kind;
|
|
14
|
-
code = "ARIVIE_TOOL_ERROR";
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
// src/compile-metric.ts
|
|
18
|
-
var FILTER_COL_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)?$/;
|
|
19
|
-
var ENTITY_COL_REF = /\b([a-zA-Z_][a-zA-Z0-9_]*)\.([a-zA-Z_][a-zA-Z0-9_]*)\b/g;
|
|
20
|
-
function isFilterPrimitive(v) {
|
|
21
|
-
return typeof v === "string" || typeof v === "number" || typeof v === "boolean" || v === null;
|
|
22
|
-
}
|
|
23
|
-
function collectEntityRefs(text) {
|
|
24
|
-
const refs = /* @__PURE__ */ new Set();
|
|
25
|
-
for (const match of text.matchAll(ENTITY_COL_REF)) {
|
|
26
|
-
refs.add(match[1]);
|
|
27
|
-
}
|
|
28
|
-
return refs;
|
|
29
|
-
}
|
|
30
|
-
function detectJoinsNeeded(entity, dimensionSqls, filterKeys) {
|
|
31
|
-
const joinTargets = new Set((entity.joins ?? []).map((j) => j.to));
|
|
32
|
-
const refs = /* @__PURE__ */ new Set();
|
|
33
|
-
for (const sql of dimensionSqls) {
|
|
34
|
-
for (const ref of collectEntityRefs(sql)) {
|
|
35
|
-
if (joinTargets.has(ref) && ref !== entity.name) {
|
|
36
|
-
refs.add(ref);
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
for (const key of filterKeys) {
|
|
41
|
-
const dot = key.indexOf(".");
|
|
42
|
-
if (dot > 0) {
|
|
43
|
-
const refEntity = key.slice(0, dot);
|
|
44
|
-
if (joinTargets.has(refEntity) && refEntity !== entity.name) {
|
|
45
|
-
refs.add(refEntity);
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
return refs;
|
|
50
|
-
}
|
|
51
|
-
function buildJoinClauses(entity, joinsNeeded) {
|
|
52
|
-
const clauses = [];
|
|
53
|
-
for (const otherEntity of joinsNeeded) {
|
|
54
|
-
const matching = (entity.joins ?? []).filter((j) => j.to === otherEntity);
|
|
55
|
-
if (matching.length === 0) {
|
|
56
|
-
continue;
|
|
57
|
-
}
|
|
58
|
-
if (matching.length > 1) {
|
|
59
|
-
throw new ToolError(
|
|
60
|
-
"join-ambiguous",
|
|
61
|
-
`multiple join paths to '${otherEntity}'; specify entityHint to disambiguate`
|
|
62
|
-
);
|
|
63
|
-
}
|
|
64
|
-
const join = matching[0];
|
|
65
|
-
clauses.push(`LEFT JOIN ${join.to} ON ${join.on}`);
|
|
66
|
-
}
|
|
67
|
-
return clauses;
|
|
68
|
-
}
|
|
69
|
-
function compileMetricForPostgres(opts) {
|
|
70
|
-
const { entity, metric } = opts;
|
|
71
|
-
const measure = entity.measures?.find((m) => m.name === metric);
|
|
72
|
-
if (measure == null) {
|
|
73
|
-
throw new ToolError(
|
|
74
|
-
"metric-not-found",
|
|
75
|
-
`metric '${metric}' not found on entity '${entity.name}'`
|
|
76
|
-
);
|
|
77
|
-
}
|
|
78
|
-
const dimensionNames = opts.dimensions ?? [];
|
|
79
|
-
const selectExprs = [`(${measure.sql}) AS "${measure.name}"`];
|
|
80
|
-
const dimensionSqls = [];
|
|
81
|
-
for (const dimName of dimensionNames) {
|
|
82
|
-
const dim = entity.dimensions?.find((d) => d.name === dimName);
|
|
83
|
-
if (dim == null) {
|
|
84
|
-
throw new ToolError(
|
|
85
|
-
"dimension-not-found",
|
|
86
|
-
`dimension '${dimName}' not found on entity '${entity.name}'`
|
|
87
|
-
);
|
|
88
|
-
}
|
|
89
|
-
selectExprs.push(`(${dim.sql}) AS "${dim.name}"`);
|
|
90
|
-
dimensionSqls.push(dim.sql);
|
|
91
|
-
}
|
|
92
|
-
const filterKeys = Object.keys(opts.filters ?? {});
|
|
93
|
-
const joinsNeeded = detectJoinsNeeded(entity, dimensionSqls, filterKeys);
|
|
94
|
-
const joinClauses = buildJoinClauses(entity, joinsNeeded);
|
|
95
|
-
const whereClauses = [];
|
|
96
|
-
const params = [];
|
|
97
|
-
for (const segName of opts.segments ?? []) {
|
|
98
|
-
const seg = entity.segments?.find((s) => s.name === segName);
|
|
99
|
-
if (seg == null) {
|
|
100
|
-
throw new ToolError(
|
|
101
|
-
"segment-not-found",
|
|
102
|
-
`segment '${segName}' not found on entity '${entity.name}'`
|
|
103
|
-
);
|
|
104
|
-
}
|
|
105
|
-
whereClauses.push(`(${seg.sql})`);
|
|
106
|
-
}
|
|
107
|
-
for (const [col, value] of Object.entries(opts.filters ?? {})) {
|
|
108
|
-
if (!FILTER_COL_PATTERN.test(col)) {
|
|
109
|
-
throw new ToolError(
|
|
110
|
-
"filter-invalid",
|
|
111
|
-
`filter column '${col}' must be a plain identifier`
|
|
112
|
-
);
|
|
113
|
-
}
|
|
114
|
-
if (!isFilterPrimitive(value)) {
|
|
115
|
-
throw new ToolError(
|
|
116
|
-
"filter-invalid",
|
|
117
|
-
`filter value for '${col}' must be string|number|boolean|null`
|
|
118
|
-
);
|
|
119
|
-
}
|
|
120
|
-
if (value === null) {
|
|
121
|
-
whereClauses.push(`${col} IS NULL`);
|
|
122
|
-
} else {
|
|
123
|
-
whereClauses.push(`${col} = $${params.length + 1}`);
|
|
124
|
-
params.push(value);
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
const fromTable = typeof entity.source === "string" ? entity.source : entity.name;
|
|
128
|
-
const parts = [`SELECT ${selectExprs.join(", ")}`, `FROM ${fromTable}`];
|
|
129
|
-
if (joinClauses.length > 0) {
|
|
130
|
-
parts.push(...joinClauses);
|
|
131
|
-
}
|
|
132
|
-
if (whereClauses.length > 0) {
|
|
133
|
-
parts.push(`WHERE ${whereClauses.join(" AND ")}`);
|
|
134
|
-
}
|
|
135
|
-
if (dimensionNames.length > 0) {
|
|
136
|
-
parts.push(
|
|
137
|
-
`GROUP BY ${dimensionNames.map((d) => `"${d}"`).join(", ")}`
|
|
138
|
-
);
|
|
139
|
-
}
|
|
140
|
-
const query = parts.join(" ");
|
|
141
|
-
return params.length > 0 ? { query, params } : { query };
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// src/identifier.ts
|
|
145
|
-
var IDENT_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
146
|
-
function escapeIdent(name) {
|
|
147
|
-
if (!IDENT_RE.test(name)) {
|
|
148
|
-
throw new ToolError("sql-invalid", `invalid identifier: ${name}`);
|
|
149
|
-
}
|
|
150
|
-
return `"${name}"`;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// src/execute.ts
|
|
154
|
-
function isPostgresError(err) {
|
|
155
|
-
return typeof err === "object" && err !== null && "code" in err;
|
|
156
|
-
}
|
|
157
|
-
async function executeImpl(sql, opts) {
|
|
158
|
-
const startedAt = Date.now();
|
|
159
|
-
if (!Number.isFinite(opts.timeoutMs) || !Number.isInteger(opts.timeoutMs) || opts.timeoutMs <= 0) {
|
|
160
|
-
throw new ToolError(
|
|
161
|
-
"sql-invalid",
|
|
162
|
-
`timeoutMs must be a positive integer; got ${String(opts.timeoutMs)}`
|
|
163
|
-
);
|
|
164
|
-
}
|
|
165
|
-
if (!Number.isFinite(opts.rowLimit) || !Number.isInteger(opts.rowLimit) || opts.rowLimit <= 0) {
|
|
166
|
-
throw new ToolError(
|
|
167
|
-
"sql-invalid",
|
|
168
|
-
`rowLimit must be a positive integer; got ${String(opts.rowLimit)}`
|
|
169
|
-
);
|
|
170
|
-
}
|
|
171
|
-
if (opts.runAsRole == null || opts.runAsRole === "") {
|
|
172
|
-
throw new ToolError("sql-invalid", "runAsRole is required");
|
|
173
|
-
}
|
|
174
|
-
const runAsRole = opts.runAsRole;
|
|
175
|
-
try {
|
|
176
|
-
const rows = await sql.begin(async (tx) => {
|
|
177
|
-
await tx.unsafe(`SET LOCAL ROLE ${escapeIdent(runAsRole)}`);
|
|
178
|
-
await tx.unsafe(
|
|
179
|
-
`SET LOCAL statement_timeout = ${opts.timeoutMs}`
|
|
180
|
-
);
|
|
181
|
-
let queryParams;
|
|
182
|
-
if (opts.params != null) {
|
|
183
|
-
const copy = [...opts.params];
|
|
184
|
-
for (let i = 0; i < copy.length; i++) {
|
|
185
|
-
const v = copy[i];
|
|
186
|
-
if (v !== null && typeof v !== "string" && typeof v !== "number" && typeof v !== "boolean") {
|
|
187
|
-
throw new ToolError(
|
|
188
|
-
"sql-invalid",
|
|
189
|
-
`params[${i}] must be string|number|boolean|null; got ${typeof v}`
|
|
190
|
-
);
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
queryParams = copy;
|
|
194
|
-
}
|
|
195
|
-
return await tx.unsafe(opts.query, queryParams);
|
|
196
|
-
});
|
|
197
|
-
const truncated = rows.length > opts.rowLimit;
|
|
198
|
-
const limited = rows.slice(0, opts.rowLimit);
|
|
199
|
-
return {
|
|
200
|
-
rows: limited,
|
|
201
|
-
rowCount: limited.length,
|
|
202
|
-
durationMs: Date.now() - startedAt,
|
|
203
|
-
truncated
|
|
204
|
-
};
|
|
205
|
-
} catch (err) {
|
|
206
|
-
if (isPostgresError(err)) {
|
|
207
|
-
if (err.code === "42501") {
|
|
208
|
-
throw new ToolError(
|
|
209
|
-
"sql-permission-denied",
|
|
210
|
-
"permission denied for SQL operation"
|
|
211
|
-
);
|
|
212
|
-
}
|
|
213
|
-
if (err.code === "57014") {
|
|
214
|
-
throw new ToolError("sql-timeout", "statement timeout");
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
throw err;
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
// src/introspect.ts
|
|
222
|
-
var PII_RE = /email|phone|ssn|address|dob|password|secret|token|card/i;
|
|
223
|
-
async function introspect(sql) {
|
|
224
|
-
const tables = await sql`
|
|
1
|
+
import {createHash}from'crypto';import F from'postgres';import {ArivieBoundaryError}from'@arivie/core/types';export{ArivieBoundaryError}from'@arivie/core/types';var s=class extends Error{constructor(n,o){super(o??n);this.kind=n;this.name="ToolError";}kind;code="ARIVIE_TOOL_ERROR"};var L=/^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)?$/,$=/\b([a-zA-Z_][a-zA-Z0-9_]*)\.([a-zA-Z_][a-zA-Z0-9_]*)\b/g;function C(t){return typeof t=="string"||typeof t=="number"||typeof t=="boolean"||t===null}function I(t){let e=new Set;for(let n of t.matchAll($))e.add(n[1]);return e}function k(t,e,n){let o=new Set((t.joins??[]).map(r=>r.to)),i=new Set;for(let r of e)for(let c of I(r))o.has(c)&&c!==t.name&&i.add(c);for(let r of n){let c=r.indexOf(".");if(c>0){let l=r.slice(0,c);o.has(l)&&l!==t.name&&i.add(l);}}return i}function v(t,e){let n=[];for(let o of e){let i=(t.joins??[]).filter(c=>c.to===o);if(i.length===0)continue;if(i.length>1)throw new s("join-ambiguous",`multiple join paths to '${o}'; specify entityHint to disambiguate`);let r=i[0];n.push(`LEFT JOIN ${r.to} ON ${r.on}`);}return n}function b(t){let e=t.entity,{metric:n}=t,o=e.measures?.find(u=>u.name===n);if(o==null)throw new s("metric-not-found",`metric '${n}' not found on entity '${e.name}'`);let i=t.dimensions??[],r=[`(${o.sql}) AS "${o.name}"`],c=[];for(let u of i){let f=e.dimensions?.find(w=>w.name===u);if(f==null)throw new s("dimension-not-found",`dimension '${u}' not found on entity '${e.name}'`);r.push(`(${f.sql}) AS "${f.name}"`),c.push(f.sql);}let l=Object.keys(t.filters??{}),p=k(e,c,l),d=v(e,p),a=[],m=[];for(let u of t.segments??[]){let f=e.segments?.find(w=>w.name===u);if(f==null)throw new s("segment-not-found",`segment '${u}' not found on entity '${e.name}'`);a.push(`(${f.sql})`);}for(let[u,f]of Object.entries(t.filters??{})){if(!L.test(u))throw new s("filter-invalid",`filter column '${u}' must be a plain identifier`);if(!C(f))throw new s("filter-invalid",`filter value for '${u}' must be string|number|boolean|null`);f===null?a.push(`${u} IS NULL`):(a.push(`${u} = $${m.length+1}`),m.push(f));}let S=typeof e.source=="string"?e.source:e.name,g=[`SELECT ${r.join(", ")}`,`FROM ${S}`];d.length>0&&g.push(...d),a.length>0&&g.push(`WHERE ${a.join(" AND ")}`),i.length>0&&g.push(`GROUP BY ${i.map(u=>`"${u}"`).join(", ")}`);let h=g.join(" ");return m.length>0?{query:h,params:m}:{query:h}}var x=/^[a-zA-Z_][a-zA-Z0-9_]*$/;function E(t){if(!x.test(t))throw new s("sql-invalid",`invalid identifier: ${t}`);return `"${t}"`}function M(t){return typeof t=="object"&&t!==null&&"code"in t}async function R(t,e){let n=Date.now();if(!Number.isFinite(e.timeoutMs)||!Number.isInteger(e.timeoutMs)||e.timeoutMs<=0)throw new s("sql-invalid",`timeoutMs must be a positive integer; got ${String(e.timeoutMs)}`);if(!Number.isFinite(e.rowLimit)||!Number.isInteger(e.rowLimit)||e.rowLimit<=0)throw new s("sql-invalid",`rowLimit must be a positive integer; got ${String(e.rowLimit)}`);let o=typeof e.runAsRole=="string"&&e.runAsRole.length>0?e.runAsRole:void 0;try{let i=await t.begin(async l=>{o!==void 0&&await l.unsafe(`SET LOCAL ROLE ${E(o)}`),await l.unsafe(`SET LOCAL statement_timeout = ${e.timeoutMs}`);let p;if(e.params!=null){let d=[...e.params];for(let a=0;a<d.length;a++){let m=d[a];if(m!==null&&typeof m!="string"&&typeof m!="number"&&typeof m!="boolean")throw new s("sql-invalid",`params[${a}] must be string|number|boolean|null; got ${typeof m}`)}p=d;}return await l.unsafe(e.query,p)}),r=i.length>e.rowLimit,c=i.slice(0,e.rowLimit);return {rows:c,rowCount:c.length,durationMs:Date.now()-n,truncated:r}}catch(i){if(M(i)){if(i.code==="42501")throw new s("sql-permission-denied","permission denied for SQL operation");if(i.code==="57014")throw new s("sql-timeout","statement timeout")}throw i}}var D=/email|phone|ssn|address|dob|password|secret|token|card/i;async function T(t){let e=await t`
|
|
225
2
|
SELECT table_name
|
|
226
3
|
FROM information_schema.tables
|
|
227
4
|
WHERE table_schema = 'public'
|
|
228
5
|
AND table_type = 'BASE TABLE'
|
|
229
6
|
ORDER BY table_name
|
|
230
|
-
|
|
231
|
-
const result = [];
|
|
232
|
-
for (const { table_name } of tables) {
|
|
233
|
-
const columns = await sql`
|
|
7
|
+
`,n=[];for(let{table_name:o}of e){let i=await t`
|
|
234
8
|
SELECT
|
|
235
9
|
c.column_name,
|
|
236
10
|
c.data_type,
|
|
@@ -244,10 +18,9 @@ async function introspect(sql) {
|
|
|
244
18
|
ON pgd.objoid = st.relid
|
|
245
19
|
AND pgd.objsubid = c.ordinal_position
|
|
246
20
|
WHERE c.table_schema = 'public'
|
|
247
|
-
AND c.table_name = ${
|
|
21
|
+
AND c.table_name = ${o}
|
|
248
22
|
ORDER BY c.ordinal_position
|
|
249
|
-
|
|
250
|
-
const pkRows = await sql`
|
|
23
|
+
`,r=await t`
|
|
251
24
|
SELECT kcu.column_name
|
|
252
25
|
FROM information_schema.table_constraints tc
|
|
253
26
|
JOIN information_schema.key_column_usage kcu
|
|
@@ -255,11 +28,10 @@ async function introspect(sql) {
|
|
|
255
28
|
AND tc.table_schema = kcu.table_schema
|
|
256
29
|
AND tc.table_name = kcu.table_name
|
|
257
30
|
WHERE tc.table_schema = 'public'
|
|
258
|
-
AND tc.table_name = ${
|
|
31
|
+
AND tc.table_name = ${o}
|
|
259
32
|
AND tc.constraint_type = 'PRIMARY KEY'
|
|
260
33
|
ORDER BY kcu.ordinal_position
|
|
261
|
-
|
|
262
|
-
const fkRows = await sql`
|
|
34
|
+
`,c=await t`
|
|
263
35
|
SELECT
|
|
264
36
|
kcu.column_name,
|
|
265
37
|
ccu.table_name AS references_table,
|
|
@@ -273,328 +45,19 @@ async function introspect(sql) {
|
|
|
273
45
|
ON ccu.constraint_name = tc.constraint_name
|
|
274
46
|
AND ccu.table_schema = tc.table_schema
|
|
275
47
|
WHERE tc.table_schema = 'public'
|
|
276
|
-
AND tc.table_name = ${
|
|
48
|
+
AND tc.table_name = ${o}
|
|
277
49
|
AND tc.constraint_type = 'FOREIGN KEY'
|
|
278
50
|
ORDER BY kcu.ordinal_position
|
|
279
|
-
|
|
280
|
-
const countRows = await sql`
|
|
51
|
+
`,p=(await t`
|
|
281
52
|
SELECT reltuples::bigint AS row_count
|
|
282
53
|
FROM pg_class
|
|
283
|
-
WHERE relname = ${
|
|
284
|
-
|
|
285
|
-
const rowCountRaw = countRows[0]?.row_count;
|
|
286
|
-
const row_count = rowCountRaw === null || rowCountRaw === void 0 ? 0 : Number(rowCountRaw);
|
|
287
|
-
result.push({
|
|
288
|
-
schema: "public",
|
|
289
|
-
name: table_name,
|
|
290
|
-
columns: columns.map((col) => {
|
|
291
|
-
const column = {
|
|
292
|
-
name: col.column_name,
|
|
293
|
-
type: col.data_type,
|
|
294
|
-
nullable: col.is_nullable === "YES"
|
|
295
|
-
};
|
|
296
|
-
if (col.comment) {
|
|
297
|
-
column.comment = col.comment;
|
|
298
|
-
}
|
|
299
|
-
if (PII_RE.test(col.column_name)) {
|
|
300
|
-
column.isPii = true;
|
|
301
|
-
}
|
|
302
|
-
return column;
|
|
303
|
-
}),
|
|
304
|
-
primary_key: pkRows.map((r) => r.column_name),
|
|
305
|
-
foreign_keys: fkRows.map((r) => ({
|
|
306
|
-
column: r.column_name,
|
|
307
|
-
references: {
|
|
308
|
-
table: r.references_table,
|
|
309
|
-
column: r.references_column
|
|
310
|
-
}
|
|
311
|
-
})),
|
|
312
|
-
row_count
|
|
313
|
-
});
|
|
314
|
-
}
|
|
315
|
-
return result;
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
// src/setup-role.ts
|
|
319
|
-
var SETUP_ROLE_LOCK_KEY = 982374623;
|
|
320
|
-
function isDuplicateRoleError(err) {
|
|
321
|
-
return err != null && typeof err === "object" && "code" in err && err.code === "42710";
|
|
322
|
-
}
|
|
323
|
-
async function setupRole(sql, role, options) {
|
|
324
|
-
const roleIdent = escapeIdent(role);
|
|
325
|
-
await sql.unsafe(`SELECT pg_advisory_lock(${SETUP_ROLE_LOCK_KEY})`);
|
|
326
|
-
try {
|
|
327
|
-
try {
|
|
328
|
-
await sql.unsafe(`CREATE ROLE ${roleIdent} LOGIN`);
|
|
329
|
-
} catch (err) {
|
|
330
|
-
if (!isDuplicateRoleError(err)) {
|
|
331
|
-
throw err;
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
await sql.unsafe(`
|
|
54
|
+
WHERE relname = ${o}
|
|
55
|
+
`)[0]?.row_count,d=p==null?0:Number(p);n.push({schema:"public",name:o,columns:i.map(a=>{let m={name:a.column_name,type:a.data_type,nullable:a.is_nullable==="YES"};return a.comment&&(m.comment=a.comment),D.test(a.column_name)&&(m.isPii=true),m}),primary_key:r.map(a=>a.column_name),foreign_keys:c.map(a=>({column:a.column_name,references:{table:a.references_table,column:a.references_column}})),row_count:d});}return n}var y=982374623;function P(t){return t!=null&&typeof t=="object"&&"code"in t&&t.code==="42710"}async function A(t,e,n){let o=E(e);await t.unsafe(`SELECT pg_advisory_lock(${y})`);try{try{await t.unsafe(`CREATE ROLE ${o} LOGIN`);}catch(r){if(!P(r))throw r}await t.unsafe(`
|
|
335
56
|
CREATE TABLE IF NOT EXISTS arivie_owner_identity (
|
|
336
57
|
key TEXT PRIMARY KEY,
|
|
337
58
|
value TEXT NOT NULL
|
|
338
59
|
);
|
|
339
|
-
`);
|
|
340
|
-
await sql.unsafe(`GRANT USAGE ON SCHEMA public TO ${roleIdent}`);
|
|
341
|
-
const allowedTables = options?.allowedTables;
|
|
342
|
-
if (allowedTables && allowedTables.length > 0) {
|
|
343
|
-
for (const table of allowedTables) {
|
|
344
|
-
await sql.unsafe(
|
|
345
|
-
`GRANT SELECT ON TABLE public.${escapeIdent(table)} TO ${roleIdent}`
|
|
346
|
-
);
|
|
347
|
-
}
|
|
348
|
-
} else {
|
|
349
|
-
await sql.unsafe(
|
|
350
|
-
`GRANT SELECT ON ALL TABLES IN SCHEMA public TO ${roleIdent}`
|
|
351
|
-
);
|
|
352
|
-
}
|
|
353
|
-
await sql.unsafe(
|
|
354
|
-
`ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO ${roleIdent}`
|
|
355
|
-
);
|
|
356
|
-
await sql.unsafe(
|
|
357
|
-
`GRANT ${roleIdent} TO CURRENT_USER WITH SET TRUE`
|
|
358
|
-
);
|
|
359
|
-
} finally {
|
|
360
|
-
await sql.unsafe(`SELECT pg_advisory_unlock(${SETUP_ROLE_LOCK_KEY})`);
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
// src/verify.ts
|
|
365
|
-
async function verifyOwnerIdentity(sql, expectedOwnerId) {
|
|
366
|
-
const rows = await sql`
|
|
60
|
+
`),await t.unsafe(`GRANT USAGE ON SCHEMA public TO ${o}`);let i=n?.allowedTables;if(i&&i.length>0)for(let r of i)await t.unsafe(`GRANT SELECT ON TABLE public.${E(r)} TO ${o}`);else await t.unsafe(`GRANT SELECT ON ALL TABLES IN SCHEMA public TO ${o}`);await t.unsafe(`ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO ${o}`),await t.unsafe(`GRANT ${o} TO CURRENT_USER WITH SET TRUE`);}finally{await t.unsafe(`SELECT pg_advisory_unlock(${y})`);}}async function O(t,e){let n=await t`
|
|
367
61
|
SELECT value FROM arivie_owner_identity WHERE key = 'owner_id'
|
|
368
|
-
`;
|
|
369
|
-
if (
|
|
370
|
-
throw new ArivieBoundaryError(
|
|
371
|
-
{
|
|
372
|
-
reason: "identity-table-missing",
|
|
373
|
-
expected: expectedOwnerId
|
|
374
|
-
},
|
|
375
|
-
"arivie_owner_identity table missing or empty; run 'arivie setup' first"
|
|
376
|
-
);
|
|
377
|
-
}
|
|
378
|
-
const dbValue = rows[0]?.value;
|
|
379
|
-
if (dbValue !== expectedOwnerId) {
|
|
380
|
-
throw new ArivieBoundaryError(
|
|
381
|
-
{
|
|
382
|
-
reason: "identity-mismatch",
|
|
383
|
-
dbValue,
|
|
384
|
-
expected: expectedOwnerId
|
|
385
|
-
},
|
|
386
|
-
`owner identity mismatch: database has '${String(dbValue)}', expected '${expectedOwnerId}'`
|
|
387
|
-
);
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
// src/adapter.ts
|
|
392
|
-
function derivePostgresAdapterId(url) {
|
|
393
|
-
try {
|
|
394
|
-
const parsed = new URL(url);
|
|
395
|
-
const host = parsed.hostname || "localhost";
|
|
396
|
-
const db = parsed.pathname.replace(/^\//, "") || "postgres";
|
|
397
|
-
return `postgres:${host}/${db}`;
|
|
398
|
-
} catch {
|
|
399
|
-
const hash = createHash("sha256").update(url).digest("hex").slice(0, 12);
|
|
400
|
-
return `postgres:${hash}`;
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
function postgresAdapter(opts) {
|
|
404
|
-
const sql = postgres(opts.url, {
|
|
405
|
-
max: opts.maxConnections ?? 10,
|
|
406
|
-
idle_timeout: (opts.idleTimeoutMs ?? 3e4) / 1e3,
|
|
407
|
-
onnotice: () => {
|
|
408
|
-
}
|
|
409
|
-
});
|
|
410
|
-
return {
|
|
411
|
-
kind: "postgres",
|
|
412
|
-
id: derivePostgresAdapterId(opts.url),
|
|
413
|
-
url: opts.url,
|
|
414
|
-
sql,
|
|
415
|
-
execute: (executeOpts) => executeImpl(sql, executeOpts),
|
|
416
|
-
introspect: () => introspect(sql),
|
|
417
|
-
verifyOwnerIdentity: (expectedOwnerId) => verifyOwnerIdentity(sql, expectedOwnerId),
|
|
418
|
-
setupRole: (role, options) => setupRole(sql, role, options),
|
|
419
|
-
compileMetric: compileMetricForPostgres,
|
|
420
|
-
close: async () => {
|
|
421
|
-
await sql.end();
|
|
422
|
-
}
|
|
423
|
-
};
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
// src/sql-guard.ts
|
|
427
|
-
var FORBIDDEN_KEYWORDS = [
|
|
428
|
-
"INSERT",
|
|
429
|
-
"UPDATE",
|
|
430
|
-
"DELETE",
|
|
431
|
-
"MERGE",
|
|
432
|
-
"TRUNCATE",
|
|
433
|
-
"DROP",
|
|
434
|
-
"CREATE",
|
|
435
|
-
"ALTER",
|
|
436
|
-
"GRANT",
|
|
437
|
-
"REVOKE",
|
|
438
|
-
"REINDEX",
|
|
439
|
-
"VACUUM",
|
|
440
|
-
"CLUSTER",
|
|
441
|
-
"COPY",
|
|
442
|
-
"CALL",
|
|
443
|
-
"DO",
|
|
444
|
-
"LOCK",
|
|
445
|
-
"COMMENT",
|
|
446
|
-
"REFRESH",
|
|
447
|
-
"REASSIGN",
|
|
448
|
-
"EXECUTE",
|
|
449
|
-
"PREPARE",
|
|
450
|
-
"DEALLOCATE",
|
|
451
|
-
"DISCARD",
|
|
452
|
-
"LISTEN",
|
|
453
|
-
"NOTIFY",
|
|
454
|
-
"UNLISTEN",
|
|
455
|
-
"SET",
|
|
456
|
-
"RESET"
|
|
457
|
-
];
|
|
458
|
-
var SYSTEM_CATALOG_PATTERN = /\b(pg_catalog|information_schema)\b/i;
|
|
459
|
-
var FORBIDDEN_PATTERN = new RegExp(
|
|
460
|
-
`\\b(${FORBIDDEN_KEYWORDS.join("|")})\\b`,
|
|
461
|
-
"i"
|
|
462
|
-
);
|
|
463
|
-
function stripLiteralsAndComments(sql) {
|
|
464
|
-
const out = [];
|
|
465
|
-
let i = 0;
|
|
466
|
-
const n = sql.length;
|
|
467
|
-
while (i < n) {
|
|
468
|
-
const c = sql[i];
|
|
469
|
-
if (c === void 0) {
|
|
470
|
-
break;
|
|
471
|
-
}
|
|
472
|
-
const next = i + 1 < n ? sql[i + 1] : "";
|
|
473
|
-
if (c === "-" && next === "-") {
|
|
474
|
-
while (i < n && sql[i] !== "\n") {
|
|
475
|
-
out.push(" ");
|
|
476
|
-
i += 1;
|
|
477
|
-
}
|
|
478
|
-
continue;
|
|
479
|
-
}
|
|
480
|
-
if (c === "/" && next === "*") {
|
|
481
|
-
out.push(" ");
|
|
482
|
-
i += 2;
|
|
483
|
-
while (i < n) {
|
|
484
|
-
if (sql[i] === "*" && i + 1 < n && sql[i + 1] === "/") {
|
|
485
|
-
out.push(" ");
|
|
486
|
-
i += 2;
|
|
487
|
-
break;
|
|
488
|
-
}
|
|
489
|
-
out.push(" ");
|
|
490
|
-
i += 1;
|
|
491
|
-
}
|
|
492
|
-
continue;
|
|
493
|
-
}
|
|
494
|
-
if (c === "'") {
|
|
495
|
-
out.push("'");
|
|
496
|
-
i += 1;
|
|
497
|
-
while (i < n) {
|
|
498
|
-
if (sql[i] === "'") {
|
|
499
|
-
if (i + 1 < n && sql[i + 1] === "'") {
|
|
500
|
-
out.push(" ");
|
|
501
|
-
i += 2;
|
|
502
|
-
continue;
|
|
503
|
-
}
|
|
504
|
-
out.push("'");
|
|
505
|
-
i += 1;
|
|
506
|
-
break;
|
|
507
|
-
}
|
|
508
|
-
out.push(" ");
|
|
509
|
-
i += 1;
|
|
510
|
-
}
|
|
511
|
-
continue;
|
|
512
|
-
}
|
|
513
|
-
if (c === '"') {
|
|
514
|
-
out.push('"');
|
|
515
|
-
i += 1;
|
|
516
|
-
while (i < n) {
|
|
517
|
-
if (sql[i] === '"') {
|
|
518
|
-
if (i + 1 < n && sql[i + 1] === '"') {
|
|
519
|
-
out.push(" ");
|
|
520
|
-
i += 2;
|
|
521
|
-
continue;
|
|
522
|
-
}
|
|
523
|
-
out.push('"');
|
|
524
|
-
i += 1;
|
|
525
|
-
break;
|
|
526
|
-
}
|
|
527
|
-
out.push(" ");
|
|
528
|
-
i += 1;
|
|
529
|
-
}
|
|
530
|
-
continue;
|
|
531
|
-
}
|
|
532
|
-
if (c === "$") {
|
|
533
|
-
const tagMatch = /^\$([A-Za-z_][A-Za-z_0-9]*)?\$/.exec(sql.slice(i));
|
|
534
|
-
if (tagMatch != null) {
|
|
535
|
-
const tag = tagMatch[0];
|
|
536
|
-
out.push(" ".repeat(tag.length));
|
|
537
|
-
i += tag.length;
|
|
538
|
-
const end = sql.indexOf(tag, i);
|
|
539
|
-
if (end === -1) {
|
|
540
|
-
while (i < n) {
|
|
541
|
-
out.push(" ");
|
|
542
|
-
i += 1;
|
|
543
|
-
}
|
|
544
|
-
continue;
|
|
545
|
-
}
|
|
546
|
-
while (i < end) {
|
|
547
|
-
out.push(" ");
|
|
548
|
-
i += 1;
|
|
549
|
-
}
|
|
550
|
-
out.push(" ".repeat(tag.length));
|
|
551
|
-
i += tag.length;
|
|
552
|
-
continue;
|
|
553
|
-
}
|
|
554
|
-
}
|
|
555
|
-
out.push(c);
|
|
556
|
-
i += 1;
|
|
557
|
-
}
|
|
558
|
-
return out.join("");
|
|
559
|
-
}
|
|
560
|
-
function firstKeyword(sql) {
|
|
561
|
-
const stripped = stripLiteralsAndComments(sql);
|
|
562
|
-
const m = /\s*\(*\s*([A-Za-z_][A-Za-z_0-9]*)/.exec(stripped);
|
|
563
|
-
return m?.[1] ? m[1].toUpperCase() : null;
|
|
564
|
-
}
|
|
565
|
-
function validateExecuteSql(sql) {
|
|
566
|
-
const trimmed = sql.trim();
|
|
567
|
-
if (trimmed.length === 0) {
|
|
568
|
-
throw new ToolError("sql-invalid", "empty query");
|
|
569
|
-
}
|
|
570
|
-
const stripped = stripLiteralsAndComments(trimmed);
|
|
571
|
-
if (stripped.includes(";")) {
|
|
572
|
-
const lastSemi = stripped.lastIndexOf(";");
|
|
573
|
-
const tail = stripped.slice(lastSemi + 1).trim();
|
|
574
|
-
if (tail.length > 0) {
|
|
575
|
-
throw new ToolError(
|
|
576
|
-
"sql-invalid",
|
|
577
|
-
"multi-statement queries are not allowed"
|
|
578
|
-
);
|
|
579
|
-
}
|
|
580
|
-
}
|
|
581
|
-
const head = firstKeyword(trimmed);
|
|
582
|
-
if (head !== "SELECT" && head !== "WITH") {
|
|
583
|
-
throw new ToolError(
|
|
584
|
-
"sql-invalid",
|
|
585
|
-
"only SELECT and WITH statements are allowed"
|
|
586
|
-
);
|
|
587
|
-
}
|
|
588
|
-
if (SYSTEM_CATALOG_PATTERN.test(stripped)) {
|
|
589
|
-
throw new ToolError("sql-blocked", "system catalog access is blocked");
|
|
590
|
-
}
|
|
591
|
-
const forbidden = FORBIDDEN_PATTERN.exec(stripped);
|
|
592
|
-
if (forbidden != null) {
|
|
593
|
-
throw new ToolError(
|
|
594
|
-
"sql-blocked",
|
|
595
|
-
`forbidden keyword '${forbidden[1]?.toUpperCase()}' in query`
|
|
596
|
-
);
|
|
597
|
-
}
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
export { ToolError, compileMetricForPostgres, postgresAdapter, validateExecuteSql };
|
|
62
|
+
`;if(n.length===0)throw new ArivieBoundaryError({reason:"identity-table-missing",expected:e},"arivie_owner_identity table missing or empty; run 'arivie setup' first");let o=n[0]?.value;if(o!==e)throw new ArivieBoundaryError({reason:"identity-mismatch",dbValue:o,expected:e},`owner identity mismatch: database has '${String(o)}', expected '${e}'`)}function U(t){try{let e=new URL(t),n=e.hostname||"localhost",o=e.pathname.replace(/^\//,"")||"postgres";return `postgres:${n}/${o}`}catch{return `postgres:${createHash("sha256").update(t).digest("hex").slice(0,12)}`}}function Y(t){let e=F(t.url,{max:t.maxConnections??10,idle_timeout:(t.idleTimeoutMs??3e4)/1e3,onnotice:()=>{}});return {kind:"postgres",id:U(t.url),url:t.url,sql:e,execute:n=>R(e,n),introspect:()=>T(e),verifyOwnerIdentity:n=>O(e,n),setupRole:(n,o)=>A(e,n,o),compileMetric:b,close:async()=>{await e.end();}}}var B=["INSERT","UPDATE","DELETE","MERGE","TRUNCATE","DROP","CREATE","ALTER","GRANT","REVOKE","REINDEX","VACUUM","CLUSTER","COPY","CALL","DO","LOCK","COMMENT","REFRESH","REASSIGN","EXECUTE","PREPARE","DEALLOCATE","DISCARD","LISTEN","NOTIFY","UNLISTEN","SET","RESET"],H=/\b(pg_catalog|information_schema)\b/i,z=new RegExp(`\\b(${B.join("|")})\\b`,"i");function N(t){let e=[],n=0,o=t.length;for(;n<o;){let i=t[n];if(i===void 0)break;let r=n+1<o?t[n+1]:"";if(i==="-"&&r==="-"){for(;n<o&&t[n]!==`
|
|
63
|
+
`;)e.push(" "),n+=1;continue}if(i==="/"&&r==="*"){for(e.push(" "),n+=2;n<o;){if(t[n]==="*"&&n+1<o&&t[n+1]==="/"){e.push(" "),n+=2;break}e.push(" "),n+=1;}continue}if(i==="'"){for(e.push("'"),n+=1;n<o;){if(t[n]==="'"){if(n+1<o&&t[n+1]==="'"){e.push(" "),n+=2;continue}e.push("'"),n+=1;break}e.push(" "),n+=1;}continue}if(i==='"'){for(e.push('"'),n+=1;n<o;){if(t[n]==='"'){if(n+1<o&&t[n+1]==='"'){e.push(" "),n+=2;continue}e.push('"'),n+=1;break}e.push(" "),n+=1;}continue}if(i==="$"){let c=/^\$([A-Za-z_][A-Za-z_0-9]*)?\$/.exec(t.slice(n));if(c!=null){let l=c[0];e.push(" ".repeat(l.length)),n+=l.length;let p=t.indexOf(l,n);if(p===-1){for(;n<o;)e.push(" "),n+=1;continue}for(;n<p;)e.push(" "),n+=1;e.push(" ".repeat(l.length)),n+=l.length;continue}}e.push(i),n+=1;}return e.join("")}function G(t){let e=N(t),n=/\s*\(*\s*([A-Za-z_][A-Za-z_0-9]*)/.exec(e);return n?.[1]?n[1].toUpperCase():null}function Z(t){let e=t.trim();if(e.length===0)throw new s("sql-invalid","empty query");let n=N(e);if(n.includes(";")){let r=n.lastIndexOf(";");if(n.slice(r+1).trim().length>0)throw new s("sql-invalid","multi-statement queries are not allowed")}let o=G(e);if(o!=="SELECT"&&o!=="WITH")throw new s("sql-invalid","only SELECT and WITH statements are allowed");if(H.test(n))throw new s("sql-blocked","system catalog access is blocked");let i=z.exec(n);if(i!=null)throw new s("sql-blocked",`forbidden keyword '${i[1]?.toUpperCase()}' in query`)}export{s as ToolError,b as compileMetricForPostgres,Y as postgresAdapter,Z as validateExecuteSql};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arivie/db-postgres",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Arivie Postgres adapter — role-scoped execute, introspect, owner-identity verification.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"CHANGELOG.md"
|
|
20
20
|
],
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@arivie/core": "
|
|
22
|
+
"@arivie/core": "2.0.0"
|
|
23
23
|
},
|
|
24
24
|
"peerDependencies": {
|
|
25
25
|
"postgres": "^3.4.9"
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"tsup": "^8.5.1",
|
|
33
33
|
"typescript": "^6.0.0",
|
|
34
34
|
"vitest": "^4.1.0",
|
|
35
|
-
"@arivie/semantic": "0.
|
|
35
|
+
"@arivie/semantic": "2.0.0"
|
|
36
36
|
},
|
|
37
37
|
"publishConfig": {
|
|
38
38
|
"access": "public",
|