@aria-framework/ask 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +77 -0
- package/README.md +144 -2
- package/assets/ask.css +81 -0
- package/assets/ask.js +430 -0
- package/bin/ask-migration.js +52 -0
- package/index.js +116 -0
- package/lib/bank.js +63 -0
- package/lib/bench.js +410 -0
- package/lib/catalogue.js +230 -0
- package/lib/check.js +357 -0
- package/lib/common.js +31 -0
- package/lib/docs.js +306 -0
- package/lib/gate.js +44 -0
- package/lib/history.js +32 -0
- package/lib/limits.js +56 -0
- package/lib/prompts.js +95 -0
- package/lib/retention.js +48 -0
- package/lib/routes.js +304 -0
- package/lib/run.js +266 -0
- package/lib/schema.js +396 -0
- package/lib/settings.js +142 -0
- package/lib/store.js +218 -0
- package/lib/stream.js +58 -0
- package/package.json +41 -4
- package/views/ask/admin.ejs +153 -0
- package/views/ask/drawer.ejs +59 -0
- package/views/ask/review.ejs +90 -0
package/lib/schema.js
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE REFERENCE TABLE DESIGN (D-0169 item 2). Each app OWNS its Ask tables and migrations — db-worker's runner reads one
|
|
3
|
+
* directory and ledgers by filename, so a package cannot ship a migration that runs — but every app BUILDS them the same
|
|
4
|
+
* way, from what is here:
|
|
5
|
+
*
|
|
6
|
+
* REFERENCE the column list of ask_questions and ask_tool_calls (the model reads and writes exactly these)
|
|
7
|
+
* referenceMigration() the reference migration, SQLite or Postgres — the hub's 105_ask / soc's 132_ask, verbatim in
|
|
8
|
+
* every statement, with the app's own `context_kind` values. The tables ONLY: tenancy
|
|
9
|
+
* (partner_id, its key, the composite key, RLS) is the app's GENERATED tenancy migration, never
|
|
10
|
+
* typed — the hub's scripts/generate-tenancy-migration.js, db-worker's tenancyMigration.
|
|
11
|
+
* bin/ask-migration.js the generator an app runs to emit its own NNN_ask.sql / NNN_ask.postgres.sql
|
|
12
|
+
* diffMigration() the check an app's test calls on its migration FILE: [] when every statement is the
|
|
13
|
+
* reference's, otherwise one sentence per difference
|
|
14
|
+
* diffLiveTables() the same check on a LIVE database (SQLite or Postgres): the tables a migration chain built
|
|
15
|
+
*
|
|
16
|
+
* WHAT IS COMPARED, AND WHAT IS NOT. diffMigration compares every SQL statement (comments and COMMENT ON stripped,
|
|
17
|
+
* whitespace collapsed) — so a changed CHECK, a dropped index or a widened bound is a difference. diffLiveTables compares
|
|
18
|
+
* on SQLite the stored CREATE statements (the same text), and on Postgres what the catalog says: each column's type,
|
|
19
|
+
* nullability, default and identity; each CHECK's column and its literal values and bounds; the foreign key to
|
|
20
|
+
* ask_questions with ON DELETE CASCADE; the indexes by name and columns; and, with `tenancy: true`, partner_id NOT NULL,
|
|
21
|
+
* RLS enabled AND forced, and a policy on each table.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
'use strict';
|
|
25
|
+
|
|
26
|
+
const TABLES = Object.freeze(['ask_questions', 'ask_tool_calls']);
|
|
27
|
+
const OUTCOMES = ['working', 'answered', 'withheld', 'not_found', 'refused_write', 'cannot', 'busy', 'off', 'error'];
|
|
28
|
+
const CALL_OUTCOMES = ['ok', 'not_found', 'ambiguous', 'error'];
|
|
29
|
+
const REASONS = ['wrong_figure', 'wrong_record', 'no_answer', 'other'];
|
|
30
|
+
const KIND = /^[a-z][a-z_]{0,31}$/;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* THE COLUMNS, in table order. `check` names the literal values and the bounds the column's CHECK holds (what the live
|
|
34
|
+
* Postgres comparison reads back); `ctx` marks the one app-supplied list.
|
|
35
|
+
*/
|
|
36
|
+
const REFERENCE = Object.freeze({
|
|
37
|
+
ask_questions: Object.freeze([
|
|
38
|
+
{ name: 'id', pg: 'bigint', sqlite: 'INTEGER', notNull: true, identity: true },
|
|
39
|
+
{ name: 'public_id', pg: 'text', sqlite: 'TEXT', notNull: true, unique: true },
|
|
40
|
+
{ name: 'asked_by', pg: 'bigint', sqlite: 'INTEGER', notNull: true },
|
|
41
|
+
{ name: 'asked_by_name', pg: 'text', sqlite: 'TEXT', notNull: true, check: { numbers: ['1', '250'] } },
|
|
42
|
+
{ name: 'acting', pg: 'integer', sqlite: 'INTEGER', notNull: true, default: '0', check: { numbers: ['0', '1'] } },
|
|
43
|
+
{ name: 'context_kind', pg: 'text', sqlite: 'TEXT', notNull: false, check: { ctx: true } },
|
|
44
|
+
{ name: 'context_ref', pg: 'text', sqlite: 'TEXT', notNull: false, check: { numbers: ['1', '64'] } },
|
|
45
|
+
{ name: 'question', pg: 'text', sqlite: 'TEXT', notNull: true, check: { numbers: ['1', '500'] } },
|
|
46
|
+
{ name: 'outcome', pg: 'text', sqlite: 'TEXT', notNull: true, check: { values: OUTCOMES } },
|
|
47
|
+
{ name: 'answer', pg: 'text', sqlite: 'TEXT', notNull: false },
|
|
48
|
+
{ name: 'result', pg: 'text', sqlite: 'TEXT', notNull: false },
|
|
49
|
+
{ name: 'endpoint', pg: 'text', sqlite: 'TEXT', notNull: false },
|
|
50
|
+
{ name: 'model', pg: 'text', sqlite: 'TEXT', notNull: false },
|
|
51
|
+
{ name: 'tokens', pg: 'bigint', sqlite: 'INTEGER', notNull: true, default: '0' },
|
|
52
|
+
{ name: 'ms', pg: 'bigint', sqlite: 'INTEGER', notNull: true, default: '0' },
|
|
53
|
+
{ name: 'prompt_hash', pg: 'text', sqlite: 'TEXT', notNull: false },
|
|
54
|
+
{ name: 'rating', pg: 'integer', sqlite: 'INTEGER', notNull: false, check: { numbers: ['-1', '1'] } },
|
|
55
|
+
{ name: 'feedback_reason', pg: 'text', sqlite: 'TEXT', notNull: false, check: { values: REASONS } },
|
|
56
|
+
{ name: 'feedback_text', pg: 'text', sqlite: 'TEXT', notNull: false, check: { numbers: ['300'] } },
|
|
57
|
+
{ name: 'reviewed_at', pg: 'text', sqlite: 'TEXT', notNull: false },
|
|
58
|
+
{ name: 'created_at', pg: 'text', sqlite: 'TEXT', notNull: true, default: 'now' }
|
|
59
|
+
].map(Object.freeze)),
|
|
60
|
+
ask_tool_calls: Object.freeze([
|
|
61
|
+
{ name: 'id', pg: 'bigint', sqlite: 'INTEGER', notNull: true, identity: true },
|
|
62
|
+
{ name: 'question_id', pg: 'bigint', sqlite: 'INTEGER', notNull: true },
|
|
63
|
+
{ name: 'position', pg: 'integer', sqlite: 'INTEGER', notNull: true, check: { numbers: ['1', '4'] } },
|
|
64
|
+
{ name: 'tool', pg: 'text', sqlite: 'TEXT', notNull: true, check: { numbers: ['1', '64'] } },
|
|
65
|
+
{ name: 'args', pg: 'text', sqlite: 'TEXT', notNull: true },
|
|
66
|
+
{ name: 'outcome', pg: 'text', sqlite: 'TEXT', notNull: true, check: { values: CALL_OUTCOMES } },
|
|
67
|
+
{ name: 'row_count', pg: 'integer', sqlite: 'INTEGER', notNull: true, default: '0' },
|
|
68
|
+
{ name: 'ms', pg: 'integer', sqlite: 'INTEGER', notNull: true, default: '0' },
|
|
69
|
+
{ name: 'created_at', pg: 'text', sqlite: 'TEXT', notNull: true, default: 'now' }
|
|
70
|
+
].map(Object.freeze))
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const INDEXES = Object.freeze({
|
|
74
|
+
ask_questions: { idx_ask_questions_asker: ['asked_by', 'created_at'], idx_ask_questions_created: ['created_at'] },
|
|
75
|
+
ask_tool_calls: { idx_ask_tool_calls_question: ['question_id'] }
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* THE GENERATED TENANCY, as the framework's tenancy generator writes it (both apps' 106 / 133 / 134): the session default
|
|
80
|
+
* of partner_id and the policy expression. Compared EXACTLY, after `exprNorm` — casts, brackets, spacing and case taken
|
|
81
|
+
* out, so the server's own deparsing ('app.scope'::text, ::integer, extra brackets) is not a difference and any change to
|
|
82
|
+
* the logic is.
|
|
83
|
+
*/
|
|
84
|
+
const PARTNER_DEFAULT = "NULLIF(current_setting('app.partner_id', true), '')::int";
|
|
85
|
+
const POLICY_EXPRESSION = "CASE current_setting('app.scope', true) WHEN 'platform' THEN true ELSE partner_id = current_setting('app.partner_id')::int END";
|
|
86
|
+
const exprNorm = (x) => String(x == null ? '' : x).replace(/::[a-z_]+( varying| precision)?/gi, '').replace(/[()]/g, '').replace(/\s+/g, ' ').trim().toLowerCase();
|
|
87
|
+
|
|
88
|
+
function kindsOf(contextKinds) {
|
|
89
|
+
if (!Array.isArray(contextKinds) || !contextKinds.length) throw new Error('ask schema: contextKinds is a non-empty list (the page context kinds the app passes)');
|
|
90
|
+
for (const k of contextKinds) if (typeof k !== 'string' || !KIND.test(k)) throw new Error(`ask schema: context kind ${JSON.stringify(k)} is not a lower-case word`);
|
|
91
|
+
if (new Set(contextKinds).size !== contextKinds.length) throw new Error('ask schema: a context kind is listed twice');
|
|
92
|
+
return contextKinds;
|
|
93
|
+
}
|
|
94
|
+
const q = (v) => `'${v}'`;
|
|
95
|
+
|
|
96
|
+
/** The statements of the reference migration, in order (no comments), for one dialect. */
|
|
97
|
+
function statementsFor(dialect, contextKinds) {
|
|
98
|
+
const kinds = kindsOf(contextKinds).map(q).join(', ');
|
|
99
|
+
const pg = dialect === 'postgres';
|
|
100
|
+
if (!pg && dialect !== 'sqlite') throw new Error(`ask schema: dialect is 'sqlite' or 'postgres', not ${JSON.stringify(dialect)}`);
|
|
101
|
+
const pk = pg ? 'BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY' : 'INTEGER PRIMARY KEY AUTOINCREMENT';
|
|
102
|
+
const big = pg ? 'BIGINT ' : 'INTEGER';
|
|
103
|
+
const trim = pg ? 'btrim' : 'trim';
|
|
104
|
+
const now = pg ? "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD HH24:MI:SS')" : "(datetime('now'))";
|
|
105
|
+
return [
|
|
106
|
+
`CREATE TABLE ask_questions (
|
|
107
|
+
id ${pk},
|
|
108
|
+
public_id TEXT NOT NULL UNIQUE,
|
|
109
|
+
asked_by ${big} NOT NULL,
|
|
110
|
+
asked_by_name TEXT NOT NULL CHECK (length(asked_by_name) BETWEEN 1 AND 250),
|
|
111
|
+
acting INTEGER NOT NULL DEFAULT 0 CHECK (acting IN (0, 1)),
|
|
112
|
+
context_kind TEXT CHECK (context_kind IS NULL OR context_kind IN (${kinds})),
|
|
113
|
+
context_ref TEXT CHECK (context_ref IS NULL OR length(context_ref) BETWEEN 1 AND 64),
|
|
114
|
+
question TEXT NOT NULL CHECK (length(${trim}(question)) >= 1 AND length(question) <= 500),
|
|
115
|
+
outcome TEXT NOT NULL CHECK (outcome IN ('working', 'answered', 'withheld', 'not_found', 'refused_write',
|
|
116
|
+
'cannot', 'busy', 'off', 'error')),
|
|
117
|
+
answer TEXT,
|
|
118
|
+
result TEXT,
|
|
119
|
+
endpoint TEXT,
|
|
120
|
+
model TEXT,
|
|
121
|
+
tokens ${big} NOT NULL DEFAULT 0,
|
|
122
|
+
ms ${big} NOT NULL DEFAULT 0,
|
|
123
|
+
prompt_hash TEXT,
|
|
124
|
+
rating INTEGER CHECK (rating IS NULL OR rating IN (-1, 1)),
|
|
125
|
+
feedback_reason TEXT CHECK (feedback_reason IS NULL OR feedback_reason IN ('wrong_figure', 'wrong_record', 'no_answer', 'other')),
|
|
126
|
+
feedback_text TEXT CHECK (feedback_text IS NULL OR length(feedback_text) <= 300),
|
|
127
|
+
reviewed_at TEXT,
|
|
128
|
+
created_at TEXT NOT NULL DEFAULT ${now}
|
|
129
|
+
)`,
|
|
130
|
+
'CREATE INDEX idx_ask_questions_asker ON ask_questions (asked_by, created_at)',
|
|
131
|
+
'CREATE INDEX idx_ask_questions_created ON ask_questions (created_at)',
|
|
132
|
+
`CREATE TABLE ask_tool_calls (
|
|
133
|
+
id ${pk},
|
|
134
|
+
question_id ${big} NOT NULL REFERENCES ask_questions(id) ON DELETE CASCADE,
|
|
135
|
+
position INTEGER NOT NULL CHECK (position BETWEEN 1 AND 4),
|
|
136
|
+
tool TEXT NOT NULL CHECK (length(tool) BETWEEN 1 AND 64),
|
|
137
|
+
args TEXT NOT NULL,
|
|
138
|
+
outcome TEXT NOT NULL CHECK (outcome IN ('ok', 'not_found', 'ambiguous', 'error')),
|
|
139
|
+
row_count INTEGER NOT NULL DEFAULT 0,
|
|
140
|
+
ms INTEGER NOT NULL DEFAULT 0,
|
|
141
|
+
created_at TEXT NOT NULL DEFAULT ${now}
|
|
142
|
+
)`,
|
|
143
|
+
'CREATE INDEX idx_ask_tool_calls_question ON ask_tool_calls (question_id)'
|
|
144
|
+
];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The reference migration's full text. `number` and `tenancyFile` only shape the header comment.
|
|
149
|
+
* @param {{dialect: 'sqlite'|'postgres', contextKinds: string[], number?: string, tenancyFile?: string}} o
|
|
150
|
+
*/
|
|
151
|
+
function referenceMigration(o) {
|
|
152
|
+
const st = statementsFor(o.dialect, o.contextKinds);
|
|
153
|
+
const n = o.number ? `${o.number}_ask` : 'NNN_ask';
|
|
154
|
+
const pg = o.dialect === 'postgres';
|
|
155
|
+
const head = [
|
|
156
|
+
`-- ${n}${pg ? '.postgres' : ''}.sql — Ask anything: ask_questions and ask_tool_calls, GENERATED from @aria-framework/ask's`,
|
|
157
|
+
'-- reference table design (bin/ask-migration.js). Do not edit a statement: the app\'s test holds this file to the',
|
|
158
|
+
'-- reference with diffMigration(), and an applied file is pinned. Change the design in the package, then add a NEW',
|
|
159
|
+
'-- migration from its changelog.',
|
|
160
|
+
'--',
|
|
161
|
+
'-- TWO TABLES, BOTH PARTNER-SCOPED: a question belongs to the partner it was asked in. The tables ONLY — partner_id, its',
|
|
162
|
+
`-- key, the composite key from ask_tool_calls to ask_questions and row-level security come from the GENERATED tenancy`,
|
|
163
|
+
`-- migration${o.tenancyFile ? ` (${o.tenancyFile})` : ''}, never typed here. Retention deletes questions; their look-ups cascade.`,
|
|
164
|
+
''
|
|
165
|
+
];
|
|
166
|
+
const body = st.map((s) => `${s};`);
|
|
167
|
+
// The pg statements are spaced as the hub's file is: the table, its indexes, its comments.
|
|
168
|
+
const comments = pg ? [
|
|
169
|
+
"COMMENT ON TABLE ask_questions IS 'Ask anything: one row per question, in the partner it was asked in; content kept per the Ask retention setting (PARTNER)';",
|
|
170
|
+
"COMMENT ON COLUMN ask_questions.asked_by IS 'the users.id of who asked; an ACTOR, not tenancy — no key, on purpose';",
|
|
171
|
+
"COMMENT ON COLUMN ask_questions.result IS 'the answer as the app rendered it (JSON): app-drawn figures and chips, never model-written links';",
|
|
172
|
+
"COMMENT ON TABLE ask_tool_calls IS 'Ask anything: one row per look-up a question''s plan ran — tool, validated arguments, rows, outcome (PARTNER)';"
|
|
173
|
+
] : [];
|
|
174
|
+
const parts = [body[0], body[1], body[2], ...comments.slice(0, 3), '', body[3], body[4], ...comments.slice(3)];
|
|
175
|
+
return `${head.join('\n')}\n${parts.join('\n')}\n`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** SQL text → its statements, comments and COMMENT ON removed, whitespace collapsed. Quote-aware for `--` and `;`. */
|
|
179
|
+
function statementsOf(sql) {
|
|
180
|
+
const out = [];
|
|
181
|
+
let cur = '';
|
|
182
|
+
let inQ = false;
|
|
183
|
+
const s = String(sql).replace(/\r\n/g, '\n');
|
|
184
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
185
|
+
const c = s[i];
|
|
186
|
+
if (inQ) { cur += c; if (c === "'") { if (s[i + 1] === "'") { cur += s[i + 1]; i += 1; } else inQ = false; } continue; }
|
|
187
|
+
if (c === "'") { inQ = true; cur += c; continue; }
|
|
188
|
+
if (c === '-' && s[i + 1] === '-') { while (i < s.length && s[i] !== '\n') i += 1; cur += ' '; continue; }
|
|
189
|
+
if (c === ';') { out.push(cur); cur = ''; continue; }
|
|
190
|
+
cur += c;
|
|
191
|
+
}
|
|
192
|
+
out.push(cur);
|
|
193
|
+
return out.map(norm).filter((x) => x && !/^COMMENT ON /i.test(x));
|
|
194
|
+
}
|
|
195
|
+
/** Whitespace collapsed, and none beside brackets and commas — so formatting is never a difference. */
|
|
196
|
+
const norm = (x) => String(x).replace(/\s+/g, ' ').replace(/\s*([(),])\s*/g, '$1').trim();
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Does an app's migration FILE hold exactly the reference statements? [] when it does; otherwise one sentence per
|
|
200
|
+
* difference, naming the statement.
|
|
201
|
+
* @param {string} sql the file's text
|
|
202
|
+
* @param {{dialect, contextKinds}} o
|
|
203
|
+
*/
|
|
204
|
+
function diffMigration(sql, o) {
|
|
205
|
+
const want = statementsFor(o.dialect, o.contextKinds).map(norm);
|
|
206
|
+
const got = statementsOf(sql);
|
|
207
|
+
const diffs = [];
|
|
208
|
+
const label = (x) => x.slice(0, 60);
|
|
209
|
+
for (const w of want) if (!got.includes(w)) diffs.push(`missing or different: ${label(w)}… (reference: ${w})`);
|
|
210
|
+
for (const g of got) if (!want.includes(g)) diffs.push(`not in the reference: ${label(g)}…`);
|
|
211
|
+
return diffs;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ── LIVE ──────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
const TENANCY_SQLITE = /,\s*partner_id\s+INTEGER\s+NOT\s+NULL\s+DEFAULT\s+1\s*(?=\)\s*$)/i;
|
|
217
|
+
|
|
218
|
+
async function liveSqlite(driver, o) {
|
|
219
|
+
const diffs = [];
|
|
220
|
+
const want = statementsFor('sqlite', o.contextKinds).map(norm);
|
|
221
|
+
const master = await driver.all("SELECT type, name, tbl_name, sql FROM sqlite_master WHERE tbl_name IN ('ask_questions', 'ask_tool_calls') AND sql IS NOT NULL");
|
|
222
|
+
for (const t of TABLES) {
|
|
223
|
+
const row = master.find((m) => m.type === 'table' && m.name === t);
|
|
224
|
+
if (!row) { diffs.push(`${t}: the table does not exist`); continue; }
|
|
225
|
+
let sql = String(row.sql);
|
|
226
|
+
const tenancy = TENANCY_SQLITE.test(sql);
|
|
227
|
+
sql = sql.replace(TENANCY_SQLITE, '');
|
|
228
|
+
if (o.tenancy === true && !tenancy) diffs.push(`${t}: no partner_id INTEGER NOT NULL DEFAULT 1 (the generated tenancy migration has not run)`);
|
|
229
|
+
if (o.tenancy === false && tenancy) diffs.push(`${t}: has partner_id, and tenancy: false was asked for`);
|
|
230
|
+
const w = want.find((x) => x.startsWith(`CREATE TABLE ${t}(`));
|
|
231
|
+
if (norm(sql) !== w) diffs.push(`${t}: its CREATE TABLE is not the reference's (stored: ${norm(sql)})`);
|
|
232
|
+
}
|
|
233
|
+
const allowed = new Set(Object.values(INDEXES).flatMap((x) => Object.keys(x)));
|
|
234
|
+
const tenancyIdx = new Set(['idx_ask_questions_partner', 'idx_ask_tool_calls_partner']);
|
|
235
|
+
for (const w of want.filter((x) => x.startsWith('CREATE INDEX'))) {
|
|
236
|
+
const name = w.split(' ')[2];
|
|
237
|
+
const m = master.find((x) => x.type === 'index' && x.name === name);
|
|
238
|
+
if (!m) diffs.push(`${name}: the index does not exist`);
|
|
239
|
+
else if (norm(m.sql) !== w) diffs.push(`${name}: the index is not the reference's (stored: ${norm(m.sql)})`);
|
|
240
|
+
}
|
|
241
|
+
for (const m of master.filter((x) => x.type === 'index' && !allowed.has(x.name) && !tenancyIdx.has(x.name))) diffs.push(`${m.name}: an index the reference does not have`);
|
|
242
|
+
if (o.tenancy === true) for (const n of tenancyIdx) if (!master.some((x) => x.type === 'index' && x.name === n)) diffs.push(`${n}: the tenancy index does not exist`);
|
|
243
|
+
for (const m of master.filter((x) => x.type === 'trigger')) diffs.push(`${m.name}: a trigger on ${m.tbl_name} the reference does not have`);
|
|
244
|
+
return diffs;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Literal values and bare numbers in a CHECK's definition, as two sorted lists of strings. */
|
|
248
|
+
function literalsOf(def) {
|
|
249
|
+
const values = [...String(def).matchAll(/'((?:[^']|'')*)'/g)].map((m) => m[1].replace(/''/g, "'"));
|
|
250
|
+
const rest = String(def).replace(/'((?:[^']|'')*)'/g, ' ');
|
|
251
|
+
const numbers = [...rest.matchAll(/(?<![\w.])-?\d+(?![\w.])/g)].map((m) => m[0]);
|
|
252
|
+
// A quoted number ('-1'::integer) is a number, not a value.
|
|
253
|
+
const quotedNumbers = values.filter((v) => /^-?\d+$/.test(v));
|
|
254
|
+
return { values: values.filter((v) => !/^-?\d+$/.test(v)).sort(), numbers: [...numbers, ...quotedNumbers].sort() };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function livePostgres(driver, o) {
|
|
258
|
+
const diffs = [];
|
|
259
|
+
const kinds = kindsOf(o.contextKinds);
|
|
260
|
+
const cols = await driver.all(
|
|
261
|
+
`SELECT table_name, column_name, data_type, is_nullable, column_default, is_identity FROM information_schema.columns
|
|
262
|
+
WHERE table_schema = current_schema() AND table_name IN ('ask_questions', 'ask_tool_calls') ORDER BY table_name, ordinal_position`);
|
|
263
|
+
const checks = await driver.all(
|
|
264
|
+
`SELECT t.relname AS table_name, c.conname, pg_get_constraintdef(c.oid) AS def, c.contype,
|
|
265
|
+
(SELECT array_to_string(array_agg(a.attname ORDER BY a.attname), ',') FROM pg_attribute a WHERE a.attrelid = c.conrelid AND a.attnum = ANY (c.conkey)) AS columns
|
|
266
|
+
FROM pg_constraint c JOIN pg_class t ON t.oid = c.conrelid JOIN pg_namespace n ON n.oid = t.relnamespace
|
|
267
|
+
WHERE n.nspname = current_schema() AND t.relname IN ('ask_questions', 'ask_tool_calls')`);
|
|
268
|
+
const idx = await driver.all(
|
|
269
|
+
`SELECT tablename AS table_name, indexname, indexdef FROM pg_indexes WHERE schemaname = current_schema() AND tablename IN ('ask_questions', 'ask_tool_calls')`);
|
|
270
|
+
const rls = await driver.all(
|
|
271
|
+
`SELECT relname, relrowsecurity, relforcerowsecurity FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
272
|
+
WHERE n.nspname = current_schema() AND relname IN ('ask_questions', 'ask_tool_calls')`);
|
|
273
|
+
const policies = await driver.all(`SELECT tablename, policyname, permissive, roles, cmd, qual, with_check FROM pg_policies WHERE schemaname = current_schema() AND tablename IN ('ask_questions', 'ask_tool_calls')`);
|
|
274
|
+
const triggers = await driver.all(`SELECT c.relname AS table_name, t.tgname FROM pg_trigger t JOIN pg_class c ON c.oid = t.tgrelid JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
275
|
+
WHERE NOT t.tgisinternal AND n.nspname = current_schema() AND c.relname IN ('ask_questions', 'ask_tool_calls')`);
|
|
276
|
+
const truthy = (v) => v === true || v === 't' || v === 'true' || v === 1;
|
|
277
|
+
if (!policies.every((x) => 'roles' in x)) throw new Error('ask schema: pg_policies returned no roles column');
|
|
278
|
+
|
|
279
|
+
for (const t of TABLES) {
|
|
280
|
+
const live = cols.filter((c) => c.table_name === t);
|
|
281
|
+
if (!live.length) { diffs.push(`${t}: the table does not exist`); continue; }
|
|
282
|
+
const ref = REFERENCE[t];
|
|
283
|
+
const names = live.map((c) => c.column_name);
|
|
284
|
+
for (const c of ref) {
|
|
285
|
+
const l = live.find((x) => x.column_name === c.name);
|
|
286
|
+
if (!l) { diffs.push(`${t}.${c.name}: missing`); continue; }
|
|
287
|
+
if (l.data_type !== c.pg) diffs.push(`${t}.${c.name}: is ${l.data_type}, the reference is ${c.pg}`);
|
|
288
|
+
if ((l.is_nullable === 'NO') !== c.notNull) diffs.push(`${t}.${c.name}: is ${l.is_nullable === 'NO' ? 'NOT NULL' : 'nullable'}, the reference is ${c.notNull ? 'NOT NULL' : 'nullable'}`);
|
|
289
|
+
const dflt = l.column_default == null ? null : String(l.column_default);
|
|
290
|
+
if (c.identity) { if (l.is_identity !== 'YES') diffs.push(`${t}.${c.name}: is not an identity column`); } else if (c.default === 'now') {
|
|
291
|
+
if (!dflt || !/to_char\(\(?now\(\)\s+AT\s+TIME\s+ZONE\s+'utc'/i.test(dflt) || !/YYYY-MM-DD HH24:MI:SS/.test(dflt)) diffs.push(`${t}.${c.name}: its default is not the UTC text now (${dflt})`);
|
|
292
|
+
} else if (c.default !== undefined) {
|
|
293
|
+
if (dflt === null || dflt.replace(/::\w+$/, '') !== c.default) diffs.push(`${t}.${c.name}: its default is ${dflt}, the reference is ${c.default}`);
|
|
294
|
+
} else if (dflt !== null) diffs.push(`${t}.${c.name}: has a default (${dflt}) the reference does not`);
|
|
295
|
+
if (c.check) {
|
|
296
|
+
const mine = checks.filter((k) => k.table_name === t && k.contype === 'c' && k.columns === c.name);
|
|
297
|
+
if (mine.length !== 1) { diffs.push(`${t}.${c.name}: has ${mine.length} CHECK constraints, the reference has 1`); continue; }
|
|
298
|
+
const got = literalsOf(mine[0].def);
|
|
299
|
+
const wantValues = (c.check.ctx ? kinds : c.check.values || []).slice().sort();
|
|
300
|
+
const wantNumbers = (c.check.numbers || []).slice().sort();
|
|
301
|
+
if (JSON.stringify(got.values) !== JSON.stringify(wantValues)) diffs.push(`${t}.${c.name}: its CHECK allows ${JSON.stringify(got.values)}, the reference ${JSON.stringify(wantValues)}`);
|
|
302
|
+
if (JSON.stringify(got.numbers) !== JSON.stringify(wantNumbers)) diffs.push(`${t}.${c.name}: its CHECK bounds are ${JSON.stringify(got.numbers)}, the reference's ${JSON.stringify(wantNumbers)}`);
|
|
303
|
+
} else if (checks.some((k) => k.table_name === t && k.contype === 'c' && k.columns === c.name)) diffs.push(`${t}.${c.name}: has a CHECK the reference does not`);
|
|
304
|
+
}
|
|
305
|
+
const extra = names.filter((n) => !ref.some((c) => c.name === n) && n !== 'partner_id');
|
|
306
|
+
for (const n of extra) diffs.push(`${t}.${n}: a column the reference does not have`);
|
|
307
|
+
const hasPartner = names.includes('partner_id');
|
|
308
|
+
const mineC = checks.filter((k) => k.table_name === t);
|
|
309
|
+
const refChecks = new Set(ref.filter((c) => c.check).map((c) => c.name));
|
|
310
|
+
for (const k of mineC.filter((x) => x.contype === 'c' && !refChecks.has(x.columns))) diffs.push(`${t}: a CHECK the reference does not have (${k.conname}: ${k.def})`);
|
|
311
|
+
if (o.tenancy === true) {
|
|
312
|
+
// REVIEW M1 OF PR #9: every part of the generated tenancy, not just its presence.
|
|
313
|
+
const p = live.find((x) => x.column_name === 'partner_id');
|
|
314
|
+
if (!p) diffs.push(`${t}: no partner_id (the generated tenancy migration has not run)`);
|
|
315
|
+
else {
|
|
316
|
+
if (p.is_nullable !== 'NO') diffs.push(`${t}.partner_id: is nullable`);
|
|
317
|
+
if (p.data_type !== 'bigint') diffs.push(`${t}.partner_id: is ${p.data_type}, the generated tenancy makes it bigint`);
|
|
318
|
+
if (exprNorm(p.column_default) !== exprNorm(PARTNER_DEFAULT)) diffs.push(`${t}.partner_id: its default is not exactly the session's partner, ${PARTNER_DEFAULT} (it is ${p.column_default})`);
|
|
319
|
+
}
|
|
320
|
+
const r = rls.find((x) => x.relname === t);
|
|
321
|
+
if (!r || !truthy(r.relrowsecurity) || !truthy(r.relforcerowsecurity)) diffs.push(`${t}: row-level security is not enabled AND forced`);
|
|
322
|
+
const pol = policies.filter((x) => x.tablename === t);
|
|
323
|
+
if (pol.length !== 1) diffs.push(`${t}: has ${pol.length} row-level security policies, the generated tenancy has exactly 1`);
|
|
324
|
+
for (const x of pol) {
|
|
325
|
+
if (x.cmd !== 'ALL') diffs.push(`${t}: policy ${x.policyname} is FOR ${x.cmd}, not FOR ALL`);
|
|
326
|
+
if (x.permissive !== 'PERMISSIVE') diffs.push(`${t}: policy ${x.policyname} is ${x.permissive}`);
|
|
327
|
+
// EXACTLY THE GENERATED EXPRESSION (re-review of PR #9), compared with casts, brackets, spacing and case taken out —
|
|
328
|
+
// so "… OR true", "<>", ">=", an IS NOT NULL form or another scope arm is a difference, on any server version.
|
|
329
|
+
const want = exprNorm(o.policyExpression || POLICY_EXPRESSION);
|
|
330
|
+
for (const [what, expr] of [['USING', x.qual], ['WITH CHECK', x.with_check]]) {
|
|
331
|
+
if (exprNorm(expr) !== want) diffs.push(`${t}: policy ${x.policyname}'s ${what} is not the generated tenancy expression (${expr || 'none'})`);
|
|
332
|
+
}
|
|
333
|
+
const roles = Array.isArray(x.roles) ? x.roles : String(x.roles || '').replace(/^\{|\}$/g, '').split(',').filter(Boolean);
|
|
334
|
+
if (roles.length !== 1 || roles[0] !== 'public') diffs.push(`${t}: policy ${x.policyname} applies TO ${roles.join(', ') || 'nobody'}, not to every role (public)`);
|
|
335
|
+
}
|
|
336
|
+
const toPartners = mineC.filter((k) => k.contype === 'f' && /REFERENCES partners\(id\)/.test(k.def) && k.columns === 'partner_id');
|
|
337
|
+
if (toPartners.length !== 1) diffs.push(`${t}.partner_id: ${toPartners.length} keys to partners(id), the generated tenancy has 1`);
|
|
338
|
+
else if (/ON DELETE (CASCADE|SET)/.test(toPartners[0].def)) diffs.push(`${t}.partner_id: its key to partners deletes or re-homes rows (${toPartners[0].def})`);
|
|
339
|
+
if (!idx.some((x) => x.table_name === t && x.indexname === `idx_${t}_partner` && /USING btree \(partner_id\)\s*$/.test(x.indexdef))) diffs.push(`idx_${t}_partner: the tenancy btree index on (partner_id) does not exist`);
|
|
340
|
+
} else if (o.tenancy === false && hasPartner) diffs.push(`${t}: has partner_id, and tenancy: false was asked for`);
|
|
341
|
+
for (const [name, on] of Object.entries(INDEXES[t])) {
|
|
342
|
+
const i = idx.find((x) => x.indexname === name);
|
|
343
|
+
const colsOf = (def) => ((/\((.*)\)\s*$/.exec(def) || [])[1] || '').split(',').map((x) => x.trim());
|
|
344
|
+
if (!i) diffs.push(`${name}: the index does not exist`);
|
|
345
|
+
else if (JSON.stringify(colsOf(i.indexdef)) !== JSON.stringify(on) || /UNIQUE/i.test(i.indexdef) || !/ USING btree \(/.test(i.indexdef)) diffs.push(`${name}: is ${i.indexdef}, the reference is a btree on (${on.join(', ')})`);
|
|
346
|
+
}
|
|
347
|
+
// NO EXTRA INDEXES OR TRIGGERS, as on the SQLite side: an index not in the reference, not the tenancy one and not backing
|
|
348
|
+
// a key or unique constraint is a difference; so is any trigger (one could rewrite partner_id).
|
|
349
|
+
const backing = new Set(mineC.filter((k) => k.contype === 'p' || k.contype === 'u').map((k) => k.conname));
|
|
350
|
+
for (const i of idx.filter((x) => x.table_name === t)) {
|
|
351
|
+
if (INDEXES[t][i.indexname] || backing.has(i.indexname) || (o.tenancy !== false && i.indexname === `idx_${t}_partner`)) continue;
|
|
352
|
+
diffs.push(`${i.indexname}: an index the reference does not have (${i.indexdef})`);
|
|
353
|
+
}
|
|
354
|
+
for (const x of triggers.filter((y) => y.table_name === t)) diffs.push(`${t}: a trigger the reference does not have (${x.tgname})`);
|
|
355
|
+
}
|
|
356
|
+
// THE LOOK-UPS' KEY TO THEIR QUESTION, ON DELETE CASCADE: with tenancy exactly the generated composite one, (question_id,
|
|
357
|
+
// partner_id) → ask_questions (id, partner_id), and the parent's UNIQUE (id, partner_id); without it, the plain one.
|
|
358
|
+
const fks = checks.filter((k) => k.table_name === 'ask_tool_calls' && k.contype === 'f' && /REFERENCES ask_questions\(/.test(k.def));
|
|
359
|
+
const composite = (k) => k.columns === 'partner_id,question_id' && /FOREIGN KEY \(question_id, partner_id\) REFERENCES ask_questions\(id, partner_id\)/.test(k.def);
|
|
360
|
+
const plain = (k) => k.columns === 'question_id' && /REFERENCES ask_questions\(id\)/.test(k.def);
|
|
361
|
+
if (!fks.length) diffs.push('ask_tool_calls.question_id: no foreign key to ask_questions');
|
|
362
|
+
if (fks.length > 1) diffs.push(`ask_tool_calls.question_id: ${fks.length} keys to ask_questions, the reference has 1`);
|
|
363
|
+
for (const k of fks) {
|
|
364
|
+
if (!/ON DELETE CASCADE/.test(k.def)) diffs.push(`ask_tool_calls: key ${k.conname} to ask_questions is not ON DELETE CASCADE`);
|
|
365
|
+
// ON UPDATE CASCADE (the generated one) or NO ACTION / RESTRICT — never SET DEFAULT or SET NULL, which re-parent a row.
|
|
366
|
+
if (/ON UPDATE SET/.test(k.def)) diffs.push(`ask_tool_calls: key ${k.conname} re-parents rows on update (${k.def})`);
|
|
367
|
+
if (o.tenancy === true && !composite(k)) diffs.push(`ask_tool_calls: key ${k.conname} is not the composite (question_id, partner_id) → ask_questions (id, partner_id) (${k.def})`);
|
|
368
|
+
if (o.tenancy !== true && !plain(k) && !composite(k)) diffs.push(`ask_tool_calls: key ${k.conname} is neither the plain nor the composite key (${k.def})`);
|
|
369
|
+
}
|
|
370
|
+
const others = checks.filter((k) => k.contype === 'f' && !fks.includes(k) && !(k.columns === 'partner_id' && /REFERENCES partners\(id\)/.test(k.def)));
|
|
371
|
+
for (const k of others) diffs.push(`${k.table_name}: a foreign key the reference does not have (${k.def})`);
|
|
372
|
+
if (o.tenancy === true && !checks.some((k) => k.table_name === 'ask_questions' && k.contype === 'u' && k.columns === 'id,partner_id')) diffs.push('ask_questions: no UNIQUE (id, partner_id) for the composite key');
|
|
373
|
+
const uniques = checks.filter((k) => k.contype === 'u' && !(k.table_name === 'ask_questions' && (k.columns === 'public_id' || k.columns === 'id,partner_id')));
|
|
374
|
+
for (const k of uniques) diffs.push(`${k.table_name}: a UNIQUE the reference does not have (${k.def})`);
|
|
375
|
+
if (!checks.some((k) => k.table_name === 'ask_questions' && k.contype === 'u' && k.columns === 'public_id')) diffs.push('ask_questions.public_id: not UNIQUE');
|
|
376
|
+
return diffs;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Do the LIVE tables match the reference? [] when they do. `tenancy`: true (partner_id + RLS required — an app on
|
|
381
|
+
* Postgres after its tenancy migration), false (must be absent), or undefined (either).
|
|
382
|
+
* @param {object} driver a db-worker driver ({ all, dialect: { kind } })
|
|
383
|
+
* @param {{contextKinds: string[], tenancy?: boolean, policyExpression?: string}} o — policyExpression: the app tenancy
|
|
384
|
+
* manifest POLICY_EXPRESSION, when it is not the framework generator default
|
|
385
|
+
*/
|
|
386
|
+
async function diffLiveTables(driver, o) {
|
|
387
|
+
const kind = driver && driver.dialect && driver.dialect.kind;
|
|
388
|
+
if (kind === 'sqlite') return liveSqlite(driver, o);
|
|
389
|
+
if (kind === 'postgres') return livePostgres(driver, o);
|
|
390
|
+
throw new Error('ask schema: diffLiveTables needs a db-worker driver (dialect.kind sqlite or postgres)');
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** The column list the model reads and writes, per table — the `SCHEMA` export (notify's precedent). */
|
|
394
|
+
const SCHEMA = Object.freeze(Object.fromEntries(TABLES.map((t) => [t, Object.freeze(REFERENCE[t].map((c) => c.name))])));
|
|
395
|
+
|
|
396
|
+
module.exports = { TABLES, REFERENCE, SCHEMA, INDEXES, referenceMigration, statementsFor, statementsOf, diffMigration, diffLiveTables, literalsOf, norm };
|
package/lib/settings.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ask settings — ONE CANONICAL SET for every app (D-0169 item 5): the same names, labels, help text, defaults and ranges,
|
|
3
|
+
* owned here. An app chooses only WHERE each value is stored:
|
|
4
|
+
*
|
|
5
|
+
* new apps (desk, Noc) the canonical keys, `ask.<name>` — pass nothing
|
|
6
|
+
* the hub `ask.<name>` — the canonical keys ARE its keys (lib/settingsCatalogue.js), nothing to map
|
|
7
|
+
* soc `ask_<name>` — pass `storageKey: (name) => \`ask_${name}\`` (its stored rows predate this)
|
|
8
|
+
*
|
|
9
|
+
* The mapping exists ONLY for backward compatibility: a rename would move stored values and the bench record for no gain.
|
|
10
|
+
* Labels and wording are never per app.
|
|
11
|
+
*
|
|
12
|
+
* DEFAULTS LIVE HERE, IN CODE (the schema seeds nothing): Ask OFF until the platform switches it on; 30 questions an hour
|
|
13
|
+
* and 200 a day per person, 500,000 tokens a day per partner; questions kept 30 days; every tool on. A stored value that
|
|
14
|
+
* does not parse falls back to the default AND is reported by `read()` as `invalid`, so the Ask tab can say so instead of
|
|
15
|
+
* silently showing the default as if it were the setting.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
'use strict';
|
|
19
|
+
|
|
20
|
+
const RETENTIONS = Object.freeze(['30', '90', 'audit']);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* THE CANONICAL SET. `name` is the canonical storage suffix; `field` the read() property; `form` the form field. Order is
|
|
24
|
+
* the Ask tab's order.
|
|
25
|
+
*/
|
|
26
|
+
const CANON = Object.freeze([
|
|
27
|
+
{ name: 'enabled', field: 'enabled', form: 'enabled', default: false,
|
|
28
|
+
label: 'Answer questions here',
|
|
29
|
+
help: 'The Ask box in the top bar, route ask. People also need ai.ask in one of their roles. Every look-up runs under the asker’s own permissions and tenancy.' },
|
|
30
|
+
{ name: 'limit_hour', field: 'limitHour', form: 'limit_hour', default: 30, min: 1, max: 10000,
|
|
31
|
+
label: 'Questions per person, per hour', help: null, error: 'Enter a whole number from 1 to 10,000.' },
|
|
32
|
+
{ name: 'limit_day', field: 'limitDay', form: 'limit_day', default: 200, min: 1, max: 100000,
|
|
33
|
+
label: 'Questions per person, per day', help: 'A day runs midnight to midnight UTC.', error: 'Enter a whole number from 1 to 100,000.' },
|
|
34
|
+
{ name: 'partner_tokens_day', field: 'partnerTokensDay', form: 'partner_tokens_day', default: 500000, min: 0, max: 1000000000,
|
|
35
|
+
label: 'Tokens per partner, per day', help: '0 means no partner limit. The endpoints’ own daily caps are installation-wide, under Endpoints.',
|
|
36
|
+
error: 'Enter a whole number of tokens (0 means no partner limit).' },
|
|
37
|
+
{ name: 'retention', field: 'retention', form: 'retention', default: '30',
|
|
38
|
+
label: 'Retention', help: 'Questions, answers, look-ups and feedback are deleted after this. The audit sentence “asked a question” stays with the audit trail.',
|
|
39
|
+
options: [['30', '30 days'], ['90', '90 days'], ['audit', 'As long as the audit trail']], error: 'Choose how long questions are kept.' },
|
|
40
|
+
{ name: 'tools_off', field: 'toolsOff', form: 'tools', default: [],
|
|
41
|
+
label: 'Tools', help: 'All read-only. A tool switched off is left out of what the AI is offered, for everyone. A tool switched on still runs only for people who hold its page permission.' }
|
|
42
|
+
].map(Object.freeze));
|
|
43
|
+
|
|
44
|
+
/** The bench record's canonical name — stored beside the settings, through the same key mapping. */
|
|
45
|
+
const BENCH = 'bench';
|
|
46
|
+
const ORDER_ERROR = 'The hourly limit cannot be above the daily one.';
|
|
47
|
+
|
|
48
|
+
const DEFAULTS = Object.freeze(Object.fromEntries(CANON.map((s) => [s.field, s.default])));
|
|
49
|
+
const canonicalKey = (name) => `ask.${name}`;
|
|
50
|
+
|
|
51
|
+
const intIn = (v, lo, hi) => {
|
|
52
|
+
if (v == null || v === '') return null;
|
|
53
|
+
if (!/^\d{1,10}$/.test(String(v).trim())) return null;
|
|
54
|
+
const n = Number(v);
|
|
55
|
+
return n >= lo && n <= hi ? n : null;
|
|
56
|
+
};
|
|
57
|
+
const byField = Object.fromEntries(CANON.map((s) => [s.field, s]));
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Settings bound to an app's store.
|
|
61
|
+
* @param {object} o
|
|
62
|
+
* @param {{get(key): Promise<string|null>, set(key, value): Promise}} o.store installation-wide keys
|
|
63
|
+
* @param {string[]} o.toolIds the catalogue's ids (tools_off may name only these)
|
|
64
|
+
* @param {Function} [o.storageKey] (canonical name) → the stored key; default `ask.<name>`
|
|
65
|
+
*/
|
|
66
|
+
function createSettings(o) {
|
|
67
|
+
if (!o || !o.store || typeof o.store.get !== 'function' || typeof o.store.set !== 'function') throw new Error('ask settings: store {get, set} is required');
|
|
68
|
+
const keyOf = typeof o.storageKey === 'function' ? o.storageKey : canonicalKey;
|
|
69
|
+
const KEYS = Object.freeze(Object.fromEntries(CANON.map((s) => [s.field, keyOf(s.name)])));
|
|
70
|
+
const BENCH_KEY = keyOf(BENCH);
|
|
71
|
+
const all = [...Object.values(KEYS), BENCH_KEY];
|
|
72
|
+
if (new Set(all).size !== all.length || all.some((k) => typeof k !== 'string' || !/^[a-z][a-z0-9_.:-]{1,63}$/.test(k))) {
|
|
73
|
+
throw new Error('ask settings: storageKey must map every canonical name to a distinct key');
|
|
74
|
+
}
|
|
75
|
+
const toolIds = Object.freeze([...(o.toolIds || [])]);
|
|
76
|
+
|
|
77
|
+
/** Every Ask setting, defaults filled in; `invalid` names any stored value that did not parse. */
|
|
78
|
+
async function read() {
|
|
79
|
+
const raw = {};
|
|
80
|
+
for (const [field, key] of Object.entries(KEYS)) raw[field] = await o.store.get(key);
|
|
81
|
+
const invalid = [];
|
|
82
|
+
const pick = (field, v) => { if (raw[field] != null && raw[field] !== '' && v == null) invalid.push(field); return v == null ? DEFAULTS[field] : v; };
|
|
83
|
+
// TOOLS OFF FAILS CLOSED (review M2 of PR #9): an unknown id in the stored list drops only itself and is reported — the
|
|
84
|
+
// known ids stay off. A list that does not parse at all switches EVERY tool off and is reported, so a damaged row can
|
|
85
|
+
// never turn tools back on.
|
|
86
|
+
let toolsOff = null;
|
|
87
|
+
if (raw.toolsOff != null && raw.toolsOff !== '') {
|
|
88
|
+
let x;
|
|
89
|
+
try { x = JSON.parse(raw.toolsOff); } catch (_) { x = undefined; }
|
|
90
|
+
if (Array.isArray(x)) {
|
|
91
|
+
toolsOff = toolIds.filter((id) => x.includes(id));
|
|
92
|
+
if (x.some((id) => !toolIds.includes(id))) invalid.push('toolsOff');
|
|
93
|
+
} else {
|
|
94
|
+
toolsOff = [...toolIds];
|
|
95
|
+
invalid.push('toolsOff');
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const lim = (f) => intIn(raw[f], byField[f].min, byField[f].max);
|
|
99
|
+
// `enabled` is 'true' or it is off; anything else stored is reported (it reads as off, the safe side).
|
|
100
|
+
if (raw.enabled != null && raw.enabled !== '' && !['true', 'false'].includes(raw.enabled)) invalid.push('enabled');
|
|
101
|
+
return {
|
|
102
|
+
enabled: raw.enabled === 'true',
|
|
103
|
+
limitHour: pick('limitHour', lim('limitHour')),
|
|
104
|
+
limitDay: pick('limitDay', lim('limitDay')),
|
|
105
|
+
partnerTokensDay: pick('partnerTokensDay', lim('partnerTokensDay')),
|
|
106
|
+
retention: pick('retention', RETENTIONS.includes(raw.retention) ? raw.retention : null),
|
|
107
|
+
toolsOff: pick('toolsOff', toolsOff),
|
|
108
|
+
invalid
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Validate a form's values. Returns { values } ready for `write`, or { errors } keyed by form field. */
|
|
113
|
+
function parseForm(body = {}) {
|
|
114
|
+
const errors = {};
|
|
115
|
+
const num = (f) => intIn(body[byField[f].form], byField[f].min, byField[f].max);
|
|
116
|
+
const limitHour = num('limitHour');
|
|
117
|
+
const limitDay = num('limitDay');
|
|
118
|
+
const partnerTokensDay = num('partnerTokensDay');
|
|
119
|
+
for (const [f, v] of [['limitHour', limitHour], ['limitDay', limitDay], ['partnerTokensDay', partnerTokensDay]]) if (v == null) errors[byField[f].form] = byField[f].error;
|
|
120
|
+
if (limitHour != null && limitDay != null && limitHour > limitDay) errors.limit_hour = ORDER_ERROR;
|
|
121
|
+
const retention = RETENTIONS.includes(String(body.retention)) ? String(body.retention) : null;
|
|
122
|
+
if (!retention) errors.retention = byField.retention.error;
|
|
123
|
+
const on = [].concat(body.tools || []).map(String);
|
|
124
|
+
const toolsOff = toolIds.filter((id) => !on.includes(id));
|
|
125
|
+
if (Object.keys(errors).length) return { errors };
|
|
126
|
+
return { values: { enabled: body.enabled === 'on' || body.enabled === 'true', limitHour, limitDay, partnerTokensDay, retention, toolsOff } };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Write validated values (from parseForm). Installation-wide. */
|
|
130
|
+
async function write(v) {
|
|
131
|
+
await o.store.set(KEYS.enabled, v.enabled ? 'true' : 'false');
|
|
132
|
+
await o.store.set(KEYS.limitHour, String(v.limitHour));
|
|
133
|
+
await o.store.set(KEYS.limitDay, String(v.limitDay));
|
|
134
|
+
await o.store.set(KEYS.partnerTokensDay, String(v.partnerTokensDay));
|
|
135
|
+
await o.store.set(KEYS.retention, v.retention);
|
|
136
|
+
await o.store.set(KEYS.toolsOff, JSON.stringify(v.toolsOff));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return { CANON, DEFAULTS, RETENTIONS, KEYS, BENCH_KEY, read, parseForm, write };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
module.exports = { createSettings, CANON, DEFAULTS, RETENTIONS, BENCH, canonicalKey, ORDER_ERROR };
|