@3minapi/cli 0.0.0-stage → 0.22.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.
Files changed (100) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +88 -2
  3. package/dist/bin.js +54 -0
  4. package/dist/cli.js +241 -0
  5. package/dist/commands/auth.js +573 -0
  6. package/dist/commands/common.js +48 -0
  7. package/dist/commands/dashboard.js +298 -0
  8. package/dist/commands/delete.js +167 -0
  9. package/dist/commands/doctor.js +326 -0
  10. package/dist/commands/guide.js +84 -0
  11. package/dist/commands/index.js +28 -0
  12. package/dist/commands/init.js +248 -0
  13. package/dist/commands/logs.js +154 -0
  14. package/dist/commands/new.js +109 -0
  15. package/dist/commands/paths.js +64 -0
  16. package/dist/commands/reload.js +32 -0
  17. package/dist/commands/results.js +445 -0
  18. package/dist/commands/start.js +443 -0
  19. package/dist/commands/stop.js +68 -0
  20. package/dist/commands/tasks.js +93 -0
  21. package/dist/commands/worker.js +178 -0
  22. package/dist/core/api.js +132 -0
  23. package/dist/core/config.js +188 -0
  24. package/dist/core/control-db.js +462 -0
  25. package/dist/core/credentials.js +75 -0
  26. package/dist/core/domain/constants.js +121 -0
  27. package/dist/core/domain/contract.js +102 -0
  28. package/dist/core/domain/errors.js +57 -0
  29. package/dist/core/domain/index.js +3 -0
  30. package/dist/core/domain/types.js +1 -0
  31. package/dist/core/error-report.js +103 -0
  32. package/dist/core/host/detach.js +49 -0
  33. package/dist/core/host/live-connection.js +270 -0
  34. package/dist/core/host/live-receiver.js +264 -0
  35. package/dist/core/host/module.js +1 -0
  36. package/dist/core/host/poller.js +113 -0
  37. package/dist/core/host/pull-step.js +54 -0
  38. package/dist/core/host/receiver.js +1 -0
  39. package/dist/core/host/reload.js +34 -0
  40. package/dist/core/host/runner-lock.js +245 -0
  41. package/dist/core/host/session.js +84 -0
  42. package/dist/core/host/usage.js +20 -0
  43. package/dist/core/log-read.js +59 -0
  44. package/dist/core/logger.js +206 -0
  45. package/dist/core/names.js +27 -0
  46. package/dist/core/network.js +10 -0
  47. package/dist/core/open-browser.js +26 -0
  48. package/dist/core/output.js +68 -0
  49. package/dist/core/paths.js +26 -0
  50. package/dist/core/project.js +85 -0
  51. package/dist/core/prompt.js +140 -0
  52. package/dist/core/redact.js +45 -0
  53. package/dist/core/secure-fs.js +50 -0
  54. package/dist/core/text-file.js +12 -0
  55. package/dist/core/text.js +27 -0
  56. package/dist/core/time.js +84 -0
  57. package/dist/core/update-notice.js +142 -0
  58. package/dist/core/version.js +16 -0
  59. package/dist/core/warnings.js +10 -0
  60. package/dist/help.js +144 -0
  61. package/dist/modules/dashboard/assets.js +411 -0
  62. package/dist/modules/dashboard/data.js +565 -0
  63. package/dist/modules/dashboard/guard.js +75 -0
  64. package/dist/modules/dashboard/lock.js +93 -0
  65. package/dist/modules/dashboard/server.js +295 -0
  66. package/dist/modules/dashboard/views/html.js +114 -0
  67. package/dist/modules/dashboard/views/layout.js +59 -0
  68. package/dist/modules/dashboard/views/logs.js +122 -0
  69. package/dist/modules/dashboard/views/overview.js +316 -0
  70. package/dist/modules/dashboard/views/runs.js +486 -0
  71. package/dist/modules/dashboard/views/workers.js +157 -0
  72. package/dist/modules/workers/builtin/host-identity.js +59 -0
  73. package/dist/modules/workers/builtin/results-worker.js +37 -0
  74. package/dist/modules/workers/local-server.js +244 -0
  75. package/dist/modules/workers/module.js +98 -0
  76. package/dist/modules/workers/reload-result.js +16 -0
  77. package/dist/modules/workers/repeat-scheduler.js +165 -0
  78. package/dist/modules/workers/results/missing-paths.js +66 -0
  79. package/dist/modules/workers/results/query.js +175 -0
  80. package/dist/modules/workers/results/reader-input.js +134 -0
  81. package/dist/modules/workers/results/reader.js +430 -0
  82. package/dist/modules/workers/results/results-child.js +64 -0
  83. package/dist/modules/workers/results/shape.js +212 -0
  84. package/dist/modules/workers/results/store.js +336 -0
  85. package/dist/modules/workers/results/substitute.js +30 -0
  86. package/dist/modules/workers/runtime/env.js +43 -0
  87. package/dist/modules/workers/runtime/error-chain.js +109 -0
  88. package/dist/modules/workers/runtime/executor.js +289 -0
  89. package/dist/modules/workers/runtime/inspect.js +53 -0
  90. package/dist/modules/workers/runtime/protocol.js +1 -0
  91. package/dist/modules/workers/runtime/resolve.js +30 -0
  92. package/dist/modules/workers/runtime/schema.js +85 -0
  93. package/dist/modules/workers/runtime/thread.js +117 -0
  94. package/dist/modules/workers/start.js +265 -0
  95. package/dist/modules/workers/sync.js +241 -0
  96. package/dist/modules/workers/task-runner.js +892 -0
  97. package/dist/modules/workers/template.js +100 -0
  98. package/dist/modules/workers/worker-set.js +122 -0
  99. package/dist/modules/workers/workers.js +66 -0
  100. package/package.json +48 -5
@@ -0,0 +1,212 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { SHAPE_MAX_DEPTH, SHAPE_MAX_PATHS, SHAPE_PATH_MAX_CHARS, SHAPE_MAX_VALUES, SHAPE_VALUE_MAX_CHARS } from "../../../core/domain/index.js";
3
+ const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
4
+ export function computeShape(value) {
5
+ const acc = new Map();
6
+ let truncated = false;
7
+ const visit = (v, path, depth) => {
8
+ let a = acc.get(path);
9
+ if (!a) {
10
+ if (acc.size >= SHAPE_MAX_PATHS || path.length > SHAPE_PATH_MAX_CHARS) {
11
+ truncated = true;
12
+ return;
13
+ }
14
+ a = { stat: { types: {}, count: 0 }, values: new Set() };
15
+ acc.set(path, a);
16
+ }
17
+ const { stat } = a;
18
+ const type = jsonType(v);
19
+ stat.types[type] = (stat.types[type] ?? 0) + 1;
20
+ stat.count++;
21
+ switch (type) {
22
+ case 'integer':
23
+ case 'real': {
24
+ const n = v;
25
+ stat.min = stat.min === undefined ? n : Math.min(stat.min, n);
26
+ stat.max = stat.max === undefined ? n : Math.max(stat.max, n);
27
+ return;
28
+ }
29
+ case 'text': {
30
+ const t = v;
31
+ if (a.values && (t.length > SHAPE_VALUE_MAX_CHARS || a.values.size >= SHAPE_MAX_VALUES)) {
32
+ if (!a.values.has(t)) {
33
+ stat.sample = (a.values.values().next().value ?? t).slice(0, SHAPE_VALUE_MAX_CHARS);
34
+ a.values = null;
35
+ }
36
+ }
37
+ if (a.values)
38
+ a.values.add(t);
39
+ else
40
+ stat.sample ??= t.slice(0, SHAPE_VALUE_MAX_CHARS);
41
+ return;
42
+ }
43
+ case 'array': {
44
+ const arr = v;
45
+ stat.length = stat.length
46
+ ? {
47
+ min: Math.min(stat.length.min, arr.length),
48
+ max: Math.max(stat.length.max, arr.length)
49
+ }
50
+ : { min: arr.length, max: arr.length };
51
+ if (depth >= SHAPE_MAX_DEPTH) {
52
+ if (arr.length > 0)
53
+ truncated = true;
54
+ return;
55
+ }
56
+ for (const item of arr)
57
+ visit(item, `${path}[*]`, depth + 1);
58
+ return;
59
+ }
60
+ case 'object': {
61
+ const entries = Object.entries(v);
62
+ if (depth >= SHAPE_MAX_DEPTH) {
63
+ if (entries.length > 0)
64
+ truncated = true;
65
+ return;
66
+ }
67
+ for (const [key, child] of entries)
68
+ visit(child, childPath(path, key), depth + 1);
69
+ return;
70
+ }
71
+ default:
72
+ return;
73
+ }
74
+ };
75
+ visit(value, '$', 0);
76
+ const paths = {};
77
+ for (const [path, a] of acc) {
78
+ if (a.values && a.values.size > 0) {
79
+ a.stat.values = [...a.values].sort();
80
+ delete a.stat.sample;
81
+ }
82
+ paths[path] = a.stat;
83
+ }
84
+ const shape = { v: 1, paths, truncated };
85
+ return { shape, hash: shapeHash(shape) };
86
+ }
87
+ export class ShapeMerger {
88
+ acc = new Map();
89
+ truncated = false;
90
+ add(shape) {
91
+ if (shape.truncated)
92
+ this.truncated = true;
93
+ for (const [path, s] of Object.entries(shape.paths)) {
94
+ let a = this.acc.get(path);
95
+ if (!a) {
96
+ a = { stat: { types: {}, count: 0 }, values: new Set() };
97
+ this.acc.set(path, a);
98
+ }
99
+ const { stat } = a;
100
+ for (const [type, n] of Object.entries(s.types)) {
101
+ stat.types[type] = (stat.types[type] ?? 0) + n;
102
+ }
103
+ stat.count += s.count;
104
+ if (s.min !== undefined)
105
+ stat.min = stat.min === undefined ? s.min : Math.min(stat.min, s.min);
106
+ if (s.max !== undefined)
107
+ stat.max = stat.max === undefined ? s.max : Math.max(stat.max, s.max);
108
+ if (s.length) {
109
+ stat.length = stat.length
110
+ ? {
111
+ min: Math.min(stat.length.min, s.length.min),
112
+ max: Math.max(stat.length.max, s.length.max)
113
+ }
114
+ : { ...s.length };
115
+ }
116
+ if (!a.values)
117
+ continue;
118
+ const first = a.values.values().next().value ?? s.values?.[0] ?? s.sample;
119
+ if (s.sample !== undefined) {
120
+ stat.sample = first;
121
+ a.values = null;
122
+ continue;
123
+ }
124
+ for (const v of s.values ?? [])
125
+ a.values.add(v);
126
+ if (a.values.size > SHAPE_MAX_VALUES) {
127
+ stat.sample = first;
128
+ a.values = null;
129
+ }
130
+ }
131
+ }
132
+ result() {
133
+ const paths = {};
134
+ for (const [path, a] of this.acc) {
135
+ if (a.values && a.values.size > 0)
136
+ a.stat.values = [...a.values].sort();
137
+ paths[path] = a.stat;
138
+ }
139
+ return { v: 1, paths, truncated: this.truncated };
140
+ }
141
+ }
142
+ export function fitShape(shape, fits) {
143
+ if (fits(shape))
144
+ return shape;
145
+ const entries = Object.entries(shape.paths).map(([p, stat]) => ({
146
+ p,
147
+ stat,
148
+ depth: pathDepth(p)
149
+ }));
150
+ const maxDepth = Math.max(0, ...entries.map((e) => e.depth));
151
+ const build = (samplesFrom, pathsFrom) => ({
152
+ v: 1,
153
+ paths: Object.fromEntries(entries
154
+ .filter((e) => e.depth < pathsFrom)
155
+ .map(({ p, stat, depth }) => {
156
+ if (depth < samplesFrom)
157
+ return [p, stat];
158
+ const { values: _values, sample: _sample, ...rest } = stat;
159
+ return [p, rest];
160
+ })),
161
+ truncated: true,
162
+ omitted: {
163
+ ...(samplesFrom <= maxDepth ? { samples_from_depth: samplesFrom } : {}),
164
+ ...(pathsFrom <= maxDepth ? { paths_from_depth: pathsFrom } : {})
165
+ }
166
+ });
167
+ let pathsFrom = maxDepth + 1;
168
+ for (let d = maxDepth; d >= 0; d--) {
169
+ const stripped = build(d, pathsFrom);
170
+ if (fits(stripped))
171
+ return stripped;
172
+ pathsFrom = d;
173
+ const dropped = build(d, pathsFrom);
174
+ if (fits(dropped))
175
+ return dropped;
176
+ }
177
+ return {
178
+ v: 1,
179
+ paths: {},
180
+ truncated: true,
181
+ omitted: { samples_from_depth: 0, paths_from_depth: 0 }
182
+ };
183
+ }
184
+ export function pathDepth(path) {
185
+ return (path.match(/\[\*\]|\.(?:"(?:[^"\\]|\\.)*"|[A-Za-z_][A-Za-z0-9_]*)/g) ?? []).length;
186
+ }
187
+ export function shapeHash(shape) {
188
+ const canonical = Object.keys(shape.paths)
189
+ .sort()
190
+ .map((p) => `${p}=${Object.keys(shape.paths[p].types).sort().join('|')}`)
191
+ .join('\n');
192
+ return createHash('sha256').update(`v${shape.v}\n${canonical}`).digest('hex').slice(0, 16);
193
+ }
194
+ export function childPath(parent, key) {
195
+ return IDENT.test(key) ? `${parent}.${key}` : `${parent}.${JSON.stringify(key)}`;
196
+ }
197
+ function jsonType(v) {
198
+ if (v === null)
199
+ return 'null';
200
+ if (Array.isArray(v))
201
+ return 'array';
202
+ switch (typeof v) {
203
+ case 'string':
204
+ return 'text';
205
+ case 'number':
206
+ return Number.isInteger(v) ? 'integer' : 'real';
207
+ case 'boolean':
208
+ return v ? 'true' : 'false';
209
+ default:
210
+ return 'object';
211
+ }
212
+ }
@@ -0,0 +1,336 @@
1
+ import { existsSync, statSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { DatabaseSync } from 'node:sqlite';
4
+ import { BIN, CliError, ConfigError, DB_BUSY_TIMEOUT_MS, RESULTS_META_TABLE, RESULTS_SCHEMA_VERSION, RESULTS_TABLE, USER_DB_FILE } from "../../../core/domain/index.js";
5
+ import { touchSecretFile } from "../../../core/secure-fs.js";
6
+ const RESULTS_COLUMNS = ` task_id TEXT NOT NULL,
7
+ worker TEXT NOT NULL,
8
+ created_at TEXT NOT NULL,
9
+ source TEXT NOT NULL,
10
+ status TEXT NOT NULL,
11
+ reply TEXT NOT NULL,
12
+ bytes INTEGER NOT NULL,
13
+ shape_hash TEXT,
14
+ shape TEXT,
15
+ host_id TEXT,
16
+ run_no INTEGER NOT NULL DEFAULT 1,
17
+ payload TEXT NOT NULL,
18
+ PRIMARY KEY (task_id, run_no)`;
19
+ const SCHEMA = `
20
+ CREATE TABLE IF NOT EXISTS ${RESULTS_TABLE} (
21
+ ${RESULTS_COLUMNS}
22
+ ) STRICT;
23
+ CREATE INDEX IF NOT EXISTS ${RESULTS_TABLE}_worker ON ${RESULTS_TABLE}(worker, created_at);
24
+ CREATE INDEX IF NOT EXISTS ${RESULTS_TABLE}_host ON ${RESULTS_TABLE}(host_id, worker, created_at);
25
+ CREATE INDEX IF NOT EXISTS ${RESULTS_TABLE}_created ON ${RESULTS_TABLE}(created_at);
26
+ CREATE TABLE IF NOT EXISTS ${RESULTS_META_TABLE} (key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT;
27
+ `;
28
+ const ROW_COLUMNS = 'task_id, worker, created_at, source, status, reply, bytes, shape_hash, host_id, run_no';
29
+ const LATEST = `SELECT ${ROW_COLUMNS}, shape, payload FROM ${RESULTS_TABLE} WHERE task_id = ? ORDER BY run_no DESC LIMIT 1`;
30
+ export function resultsDbPath(root) {
31
+ return path.join(root, USER_DB_FILE);
32
+ }
33
+ export function resultsDbBytes(root) {
34
+ const file = resultsDbPath(root);
35
+ if (!existsSync(file))
36
+ return null;
37
+ const wal = `${file}-wal`;
38
+ return statSync(file).size + (existsSync(wal) ? statSync(wal).size : 0);
39
+ }
40
+ export class ResultStore {
41
+ db;
42
+ hostId = null;
43
+ constructor(db) {
44
+ this.db = db;
45
+ }
46
+ static open(root) {
47
+ const file = resultsDbPath(root);
48
+ if (!existsSync(file))
49
+ touchSecretFile(file);
50
+ const db = new DatabaseSync(file, { timeout: DB_BUSY_TIMEOUT_MS });
51
+ try {
52
+ const warnings = ensureWal(db);
53
+ migrate(db);
54
+ return { store: new ResultStore(db), warnings };
55
+ }
56
+ catch (err) {
57
+ if (db.isOpen)
58
+ db.close();
59
+ throw err;
60
+ }
61
+ }
62
+ static openReadOnly(root) {
63
+ const file = resultsDbPath(root);
64
+ if (!existsSync(file))
65
+ return null;
66
+ let db;
67
+ try {
68
+ db = new DatabaseSync(file, { readOnly: true, timeout: DB_BUSY_TIMEOUT_MS });
69
+ if (!hasTable(db, RESULTS_TABLE)) {
70
+ db.close();
71
+ return null;
72
+ }
73
+ if (!hasColumn(db, RESULTS_TABLE, 'run_no')) {
74
+ throw new ConfigError(`the result table in ${file} was made by an older 3minapi — run '${BIN} start' or '${BIN} worker results' once to upgrade it`);
75
+ }
76
+ const schema = hasTable(db, RESULTS_META_TABLE)
77
+ ? db.prepare(`SELECT value FROM ${RESULTS_META_TABLE} WHERE key = 'schema'`).get()?.value
78
+ : undefined;
79
+ if (schema !== undefined && Number(schema) > RESULTS_SCHEMA_VERSION) {
80
+ throw new ConfigError(`the result tables in ${file} were made by a newer 3minapi (schema ${schema}) — update the CLI`);
81
+ }
82
+ return new ResultStore(db);
83
+ }
84
+ catch (err) {
85
+ if (db?.isOpen)
86
+ db.close();
87
+ if (err instanceof CliError)
88
+ throw err;
89
+ throw new CliError(`cannot open ${file}: ${err?.message ?? String(err)}`);
90
+ }
91
+ }
92
+ setHost(hostId) {
93
+ this.hostId = hostId;
94
+ }
95
+ save(rec) {
96
+ this.db
97
+ .prepare(`INSERT INTO ${RESULTS_TABLE} (task_id, worker, created_at, source, status, reply, bytes, shape_hash, shape, payload, host_id, run_no)
98
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
99
+ ON CONFLICT(task_id, run_no) DO UPDATE SET
100
+ worker = excluded.worker, created_at = excluded.created_at, source = excluded.source,
101
+ status = excluded.status, reply = excluded.reply, bytes = excluded.bytes,
102
+ shape_hash = excluded.shape_hash, shape = excluded.shape, payload = excluded.payload,
103
+ host_id = excluded.host_id`)
104
+ .run(rec.taskId, rec.worker, new Date(rec.createdAt).toISOString(), rec.source, rec.status, rec.reply, Buffer.byteLength(rec.payload), rec.shapeHash, rec.shape === null ? null : JSON.stringify(rec.shape), rec.payload, this.hostId, rec.runNo ?? 1);
105
+ }
106
+ setReply(taskId, reply, runNo = 1) {
107
+ this.db
108
+ .prepare(`UPDATE ${RESULTS_TABLE} SET reply = ? WHERE task_id = ? AND run_no = ?`)
109
+ .run(reply, taskId, runNo);
110
+ }
111
+ list(filter, limit) {
112
+ const { where, params } = whereClause(filter);
113
+ return this.db
114
+ .prepare(`SELECT ${ROW_COLUMNS} FROM ${RESULTS_TABLE} ${where} ORDER BY created_at DESC, task_id LIMIT ?`)
115
+ .all(...params, limit);
116
+ }
117
+ rowsByIds(ids) {
118
+ if (ids.length === 0)
119
+ return [];
120
+ return this.db
121
+ .prepare(`SELECT ${ROW_COLUMNS} FROM ${RESULTS_TABLE} r WHERE task_id IN (${ids.map(() => '?').join(', ')})
122
+ AND run_no = (SELECT max(run_no) FROM ${RESULTS_TABLE} WHERE task_id = r.task_id)`)
123
+ .all(...ids);
124
+ }
125
+ get(taskId, runNo) {
126
+ const row = (runNo === undefined
127
+ ? this.db.prepare(LATEST).get(taskId)
128
+ : this.db
129
+ .prepare(`SELECT ${ROW_COLUMNS}, shape, payload FROM ${RESULTS_TABLE} WHERE task_id = ? AND run_no = ?`)
130
+ .get(taskId, runNo));
131
+ return row ? stored(row) : null;
132
+ }
133
+ runs(taskId, limit, before) {
134
+ return this.db
135
+ .prepare(`SELECT ${ROW_COLUMNS} FROM ${RESULTS_TABLE} WHERE task_id = ?${before === undefined ? '' : ' AND run_no < ?'}
136
+ ORDER BY run_no DESC LIMIT ?`)
137
+ .all(...(before === undefined ? [taskId, limit] : [taskId, before, limit]));
138
+ }
139
+ runPreviews(taskId, limit, before, maxBytes) {
140
+ return this.db
141
+ .prepare(`SELECT ${ROW_COLUMNS}, CASE WHEN bytes <= ? THEN payload END AS payload FROM ${RESULTS_TABLE}
142
+ WHERE task_id = ?${before === undefined ? '' : ' AND run_no < ?'} ORDER BY run_no DESC LIMIT ?`)
143
+ .all(...(before === undefined ? [maxBytes, taskId, limit] : [maxBytes, taskId, before, limit]));
144
+ }
145
+ nextRunNo(taskId, runNo) {
146
+ const row = this.db
147
+ .prepare(`SELECT min(run_no) AS n FROM ${RESULTS_TABLE} WHERE task_id = ? AND run_no > ?`)
148
+ .get(taskId, runNo);
149
+ return row?.n ?? null;
150
+ }
151
+ runCounts(ids) {
152
+ const out = new Map();
153
+ if (ids.length === 0)
154
+ return out;
155
+ const rows = this.db
156
+ .prepare(`SELECT task_id, count(*) AS runs, max(run_no) AS last FROM ${RESULTS_TABLE}
157
+ WHERE task_id IN (${ids.map(() => '?').join(', ')}) GROUP BY task_id`)
158
+ .all(...ids);
159
+ for (const r of rows)
160
+ out.set(r.task_id, { runs: Number(r.runs), lastRunNo: Number(r.last) });
161
+ return out;
162
+ }
163
+ find(idOrPrefix) {
164
+ const exact = this.db.prepare(LATEST).get(idOrPrefix);
165
+ if (exact)
166
+ return { found: stored(exact) };
167
+ const ids = this.db
168
+ .prepare(`SELECT DISTINCT task_id FROM ${RESULTS_TABLE} WHERE task_id >= ? AND task_id < ? ORDER BY task_id LIMIT 6`)
169
+ .all(idOrPrefix, `${idOrPrefix}\uffff`);
170
+ if (ids.length === 1) {
171
+ const row = this.db.prepare(LATEST).get(ids[0].task_id);
172
+ return { found: stored(row) };
173
+ }
174
+ return { candidates: ids.map((r) => r.task_id) };
175
+ }
176
+ snapshot(read) {
177
+ this.db.exec('BEGIN');
178
+ try {
179
+ return read();
180
+ }
181
+ finally {
182
+ this.db.exec('COMMIT');
183
+ }
184
+ }
185
+ shapeGroups(filter) {
186
+ const { where, params } = shapeWhere(filter);
187
+ return this.db
188
+ .prepare(`SELECT shape_hash, count(*) AS count, min(created_at) AS first, max(created_at) AS last
189
+ FROM ${RESULTS_TABLE} ${where} GROUP BY shape_hash ORDER BY count DESC, first, shape_hash`)
190
+ .all(...params);
191
+ }
192
+ latestShapeHash(filter) {
193
+ const { where, params } = shapeWhere(filter);
194
+ const row = this.db
195
+ .prepare(`SELECT shape_hash FROM ${RESULTS_TABLE} ${where} ORDER BY created_at DESC, task_id DESC LIMIT 1`)
196
+ .get(...params);
197
+ return row?.shape_hash ?? null;
198
+ }
199
+ recentTaskIds(filter, shapeHash, limit) {
200
+ const { where, params } = shapeWhere(filter, shapeHash);
201
+ return this.db
202
+ .prepare(`SELECT task_id FROM ${RESULTS_TABLE} ${where}
203
+ GROUP BY task_id ORDER BY max(created_at) DESC, task_id DESC LIMIT ?`)
204
+ .all(...params, limit).map((r) => r.task_id);
205
+ }
206
+ recentShapes(filter, shapeHash, limit) {
207
+ const { where, params } = shapeWhere(filter, shapeHash);
208
+ return this.db
209
+ .prepare(`SELECT created_at, shape FROM (
210
+ SELECT created_at, task_id, shape FROM ${RESULTS_TABLE} ${where}
211
+ ORDER BY created_at DESC, task_id DESC LIMIT ?
212
+ ) ORDER BY created_at, task_id`)
213
+ .iterate(...params, limit);
214
+ }
215
+ usage(filter = {}) {
216
+ const { where, params } = whereClause(filter);
217
+ const row = this.db
218
+ .prepare(`SELECT count(*) AS count, coalesce(sum(bytes), 0) AS bytes FROM ${RESULTS_TABLE} ${where}`)
219
+ .get(...params);
220
+ return { count: row.count, bytes: row.bytes };
221
+ }
222
+ prune(filter) {
223
+ const { where, params } = whereClause(filter);
224
+ return Number(this.db.prepare(`DELETE FROM ${RESULTS_TABLE} ${where}`).run(...params).changes);
225
+ }
226
+ close() {
227
+ if (this.db.isOpen)
228
+ this.db.close();
229
+ }
230
+ }
231
+ function stored(raw) {
232
+ return { ...raw, shape: raw.shape === null ? null : JSON.parse(raw.shape) };
233
+ }
234
+ function whereClause(filter, extra = []) {
235
+ const conds = [...extra];
236
+ const params = [];
237
+ if (filter.worker !== undefined) {
238
+ conds.push('worker = ?');
239
+ params.push(filter.worker);
240
+ }
241
+ if (filter.since !== undefined) {
242
+ conds.push('created_at >= ?');
243
+ params.push(filter.since);
244
+ }
245
+ if (filter.before !== undefined) {
246
+ conds.push('created_at < ?');
247
+ params.push(filter.before);
248
+ }
249
+ if (filter.hostId !== undefined) {
250
+ conds.push('host_id = ?');
251
+ params.push(filter.hostId);
252
+ }
253
+ if (filter.status !== undefined) {
254
+ conds.push('status = ?');
255
+ params.push(filter.status);
256
+ }
257
+ if (filter.after !== undefined) {
258
+ conds.push('created_at <= ? AND (created_at < ? OR task_id > ?)');
259
+ params.push(filter.after.createdAt, filter.after.createdAt, filter.after.taskId);
260
+ }
261
+ if (filter.sources !== undefined) {
262
+ conds.push(`source IN (${filter.sources.map(() => '?').join(', ') || 'NULL'})`);
263
+ params.push(...filter.sources);
264
+ }
265
+ if (filter.latestRunOnly) {
266
+ conds.push(`run_no = (SELECT max(x.run_no) FROM ${RESULTS_TABLE} x WHERE x.task_id = ${RESULTS_TABLE}.task_id)`);
267
+ }
268
+ return { where: conds.length > 0 ? `WHERE ${conds.join(' AND ')}` : '', params };
269
+ }
270
+ function shapeWhere(filter, shapeHash) {
271
+ const { where, params } = whereClause(filter, [
272
+ "status = 'completed'",
273
+ shapeHash === undefined ? 'shape_hash IS NOT NULL' : 'shape_hash = ?'
274
+ ]);
275
+ return { where, params: shapeHash === undefined ? params : [shapeHash, ...params] };
276
+ }
277
+ function hasTable(db, name) {
278
+ return (db.prepare(`SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = ?`).get(name) !==
279
+ undefined);
280
+ }
281
+ function ensureWal(db) {
282
+ const mode = db.prepare('PRAGMA journal_mode').get().journal_mode;
283
+ if (mode === 'wal')
284
+ return [];
285
+ try {
286
+ const now = db.prepare('PRAGMA journal_mode = WAL').get()
287
+ .journal_mode;
288
+ return now === 'wal' ? [] : [`data.db stays in ${now} journal mode (could not switch to WAL)`];
289
+ }
290
+ catch (err) {
291
+ return [`data.db could not switch to WAL: ${err.message}`];
292
+ }
293
+ }
294
+ function migrate(db) {
295
+ db.exec('BEGIN IMMEDIATE');
296
+ try {
297
+ if (hasTable(db, RESULTS_TABLE) && !hasColumn(db, RESULTS_TABLE, 'run_no'))
298
+ rebuildWithRunNo(db);
299
+ db.exec(SCHEMA);
300
+ const row = db.prepare(`SELECT value FROM ${RESULTS_META_TABLE} WHERE key = 'schema'`).get();
301
+ if (row === undefined) {
302
+ db.prepare(`INSERT INTO ${RESULTS_META_TABLE} (key, value) VALUES ('schema', ?)`).run(String(RESULTS_SCHEMA_VERSION));
303
+ }
304
+ else if (Number(row.value) > RESULTS_SCHEMA_VERSION) {
305
+ throw new Error(`the result tables in data.db were made by a newer 3minapi (schema ${row.value}) — update the CLI`);
306
+ }
307
+ db.exec('COMMIT');
308
+ }
309
+ catch (err) {
310
+ if (db.isTransaction)
311
+ db.exec('ROLLBACK');
312
+ throw err;
313
+ }
314
+ }
315
+ function hasColumn(db, table, column) {
316
+ return db.prepare(`PRAGMA table_info(${table})`).all().some((c) => c.name === column);
317
+ }
318
+ function rebuildWithRunNo(db) {
319
+ const tmp = `${RESULTS_TABLE}_rebuild`;
320
+ const objects = db
321
+ .prepare(`SELECT type, name, sql FROM sqlite_schema
322
+ WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%'
323
+ AND ((type IN ('index', 'trigger') AND tbl_name = ?) OR (type = 'view' AND instr(sql, ?) > 0))`)
324
+ .all(RESULTS_TABLE, RESULTS_TABLE);
325
+ for (const v of objects.filter((o) => o.type === 'view'))
326
+ db.exec(`DROP VIEW "${v.name.replaceAll('"', '""')}"`);
327
+ db.exec(`CREATE TABLE ${tmp} (\n${RESULTS_COLUMNS}\n) STRICT`);
328
+ db.exec(`INSERT INTO ${tmp} (task_id, worker, created_at, source, status, reply, bytes, shape_hash, shape, host_id, run_no, payload)
329
+ SELECT task_id, worker, created_at, source, status, reply, bytes, shape_hash, shape, host_id, 1, payload FROM ${RESULTS_TABLE}`);
330
+ db.exec(`DROP TABLE ${RESULTS_TABLE}`);
331
+ db.exec(`ALTER TABLE ${tmp} RENAME TO ${RESULTS_TABLE}`);
332
+ for (const type of ['index', 'trigger', 'view']) {
333
+ for (const o of objects.filter((x) => x.type === type))
334
+ db.exec(o.sql);
335
+ }
336
+ }
@@ -0,0 +1,30 @@
1
+ import { RESULT_RESERVED_KEY, SUBSTITUTE_MAX_BYTES } from "../../../core/domain/index.js";
2
+ import { formatCount } from "../../../core/text.js";
3
+ import { fitShape } from "./shape.js";
4
+ export const SUBSTITUTE_READ = "The full result is too large to send and is stored on the user's PC. Do not present this summary as the result. " +
5
+ '`shape` summarizes its structure: the paths it has, with types, counts and sample values — an overview; ' +
6
+ 'deeper levels may be left out (`omitted`), and {"op":"describe","task_id":"<task_id>"} returns them. ' +
7
+ 'Compute the answer over those paths by creating a task for the worker in `reader` with input ' +
8
+ '{"op":"query","task_id":"<task_id>","sql":"SELECT … FROM results"} — help(tasks) explains the view and examples.';
9
+ export const SUBSTITUTE_READ_LOCAL_ONLY = "The full result is too large to send and is stored on the user's PC. The user can see it with " +
10
+ "'3minapi worker results show <task_id>' or query it with '3minapi worker results query'. " +
11
+ '`shape` summarizes its structure: paths, types, counts and sample values — an overview; deeper ' +
12
+ 'levels may be left out (`omitted`).';
13
+ export function substituteResult(input) {
14
+ const build = (shape) => ({
15
+ [RESULT_RESERVED_KEY]: {
16
+ stored_locally: true,
17
+ task_id: input.taskId,
18
+ worker: input.worker,
19
+ bytes: input.bytes,
20
+ shape_hash: input.shapeHash,
21
+ shape,
22
+ ...(input.reader ? { reader: input.reader } : {}),
23
+ read: input.reader ? SUBSTITUTE_READ : SUBSTITUTE_READ_LOCAL_ONLY
24
+ }
25
+ });
26
+ return build(fitShape(input.shape, (s) => Buffer.byteLength(JSON.stringify(build(s))) <= SUBSTITUTE_MAX_BYTES));
27
+ }
28
+ export function substituteStatusMessage(bytes) {
29
+ return `result stored on the user's PC (${formatCount(bytes)} bytes)`;
30
+ }
@@ -0,0 +1,43 @@
1
+ import { statSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { parseEnv } from 'node:util';
4
+ import { CLI_ENV_PREFIX, ConfigError } from "../../../core/domain/index.js";
5
+ import { readUserText } from "../../../core/text-file.js";
6
+ export const DOTENV_FILE = '.env';
7
+ const cache = new Map();
8
+ export function readDotEnv(projectRoot) {
9
+ const file = path.join(projectRoot, DOTENV_FILE);
10
+ let text;
11
+ try {
12
+ const stat = statSync(file, { throwIfNoEntry: false });
13
+ if (!stat) {
14
+ cache.delete(file);
15
+ return {};
16
+ }
17
+ const hit = cache.get(file);
18
+ if (hit && hit.mtimeMs === stat.mtimeMs && hit.size === stat.size)
19
+ return { ...hit.values };
20
+ text = readUserText(file);
21
+ const values = parseEnv(text);
22
+ cache.set(file, { mtimeMs: stat.mtimeMs, size: stat.size, values });
23
+ return { ...values };
24
+ }
25
+ catch (err) {
26
+ if (err instanceof ConfigError)
27
+ throw err;
28
+ if (err.code === 'ENOENT')
29
+ return {};
30
+ throw new ConfigError(`cannot read ${file}: ${err.message}`);
31
+ }
32
+ }
33
+ export function workerEnv(projectRoot, base) {
34
+ const merged = { ...base, ...readDotEnv(projectRoot) };
35
+ for (const key of Object.keys(merged)) {
36
+ if (key.startsWith(CLI_ENV_PREFIX))
37
+ delete merged[key];
38
+ }
39
+ return merged;
40
+ }
41
+ export function publicKeyEnv(projectRoot, base) {
42
+ return { ...readDotEnv(projectRoot), ...base };
43
+ }