@wpelevator/tug 0.2.0 → 0.2.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/README.md +1 -0
- package/dist/agent.js +27 -5
- package/dist/cli.js +12 -3
- package/dist/control.d.ts +2 -1
- package/dist/control.js +72 -9
- package/dist/db.d.ts +6 -0
- package/dist/db.js +6 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -47,6 +47,7 @@ The CLI installs the matching `ghcr.io/wpelevator/tug:<version>` image on your h
|
|
|
47
47
|
- [Configuration](docs/configuration.md): CLI, control and agent environment variables.
|
|
48
48
|
- [Development](docs/development.md): local setup, CI, Compose stack, host mode and the integration test.
|
|
49
49
|
- [Testing](docs/testing.md): test layers, the numbers rollouts are held to, and the backlog of mocked tests.
|
|
50
|
+
- [Observability](docs/observability.md): what can be observed today, and the proposed logs, metrics and alerting.
|
|
50
51
|
- [Releases](docs/releases.md): changesets, npm and Docker image publishing.
|
|
51
52
|
- [Standalone binary](docs/standalone-binary.md): proposed Bun-compiled CLI binaries and Homebrew distribution.
|
|
52
53
|
- [Roadmap](docs/roadmap.md): planned milestones toward parity with and beyond uncloud.
|
package/dist/agent.js
CHANGED
|
@@ -7,6 +7,7 @@ import { validateImageArchive, verifyDownload } from "./artifacts.js";
|
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import { spawn, spawnSync } from 'node:child_process';
|
|
9
9
|
import net from 'node:net';
|
|
10
|
+
import { createHash } from 'node:crypto';
|
|
10
11
|
import { composeForNode, dumpYaml, isRolling, serviceSecretVersions } from "./compose.js";
|
|
11
12
|
import { activeRollingStates, composeBase, hasLegacyContainers, reconcileRolling, retireAll } from "./rollout.js";
|
|
12
13
|
import { ensureDir, errMessage, now, parseDuration } from "./util.js";
|
|
@@ -279,7 +280,7 @@ export async function reconcile(desiredState, ctx = defaultContext(), endpoints
|
|
|
279
280
|
return { project: desiredState.project, observedVersion: desiredState.version, ...(desiredState.revision ? { observedRevision: desiredState.revision } : {}), health: 'skipped', services: [], serviceStates: {} };
|
|
280
281
|
}
|
|
281
282
|
const serviceStates = Object.fromEntries(serviceNames.map(service => [service, { rolloutState: 'pending', image: desiredState.services[service]?.image }]));
|
|
282
|
-
const emit = (service, rolloutState, message = rolloutState) => api(ctx, '/agent/v1/events', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ projectId: desiredState.project, level
|
|
283
|
+
const emit = (service, rolloutState, message = rolloutState, level = 'info') => api(ctx, '/agent/v1/events', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ projectId: desiredState.project, level, type: `service.${rolloutState}`, message, metadata: { service, version: desiredState.version, node: ctx.nodeName } }) }).catch(() => { });
|
|
283
284
|
const sec = await api(ctx, `/agent/v1/secrets?project=${encodeURIComponent(desiredState.project)}&services=${encodeURIComponent(serviceNames.join(','))}`);
|
|
284
285
|
const currentSecretVersions = serviceSecretVersions(desiredState.compose, serviceNames, sec.versions || {});
|
|
285
286
|
const db = await agentDb(ctx.stateDir);
|
|
@@ -295,11 +296,20 @@ export async function reconcile(desiredState, ctx = defaultContext(), endpoints
|
|
|
295
296
|
await fs.writeFile(path.join(dir, 'docker-compose.yml'), dumpYaml(nodeCompose));
|
|
296
297
|
await writeEnv(path.join(dir, '.env'), sec.secrets || {});
|
|
297
298
|
const failedStatus = (error) => ({ project: desiredState.project, observedVersion: desiredState.version, ...(desiredState.revision ? { observedRevision: desiredState.revision } : {}), health: 'failed', services: serviceNames, serviceStates, error });
|
|
299
|
+
// Lifecycle events say what changed, not that the agent is still polling. They are sent when the desired revision,
|
|
300
|
+
// this node's rendered configuration or the secrets change, when an apply fails (once) and when it recovers (once).
|
|
301
|
+
const eventFingerprint = createHash('sha256').update(JSON.stringify([desiredState.revision || desiredState.version, dumpYaml(nodeCompose), mainServices.map(service => currentSecretVersions[service] || {})])).digest('hex').slice(0, 16);
|
|
302
|
+
const knownEvents = mainServices.length ? await db.get('SELECT fingerprint, phase FROM event_state WHERE project = ?', desiredState.project) : undefined;
|
|
303
|
+
const announce = knownEvents?.fingerprint !== eventFingerprint;
|
|
304
|
+
const wasFailed = !announce && knownEvents?.phase === 'failed';
|
|
305
|
+
const markEvents = (phase) => db.run('INSERT INTO event_state(project, fingerprint, phase) VALUES(?,?,?) ON CONFLICT(project) DO UPDATE SET fingerprint=excluded.fingerprint, phase=excluded.phase', desiredState.project, eventFingerprint, phase);
|
|
306
|
+
let failing;
|
|
298
307
|
if (ctx.apply) {
|
|
299
308
|
try {
|
|
300
309
|
for (const service of mainServices) {
|
|
301
310
|
serviceStates[service].rolloutState = 'pulling';
|
|
302
|
-
|
|
311
|
+
if (announce)
|
|
312
|
+
await emit(service, 'pulling');
|
|
303
313
|
}
|
|
304
314
|
for (const service of mainServices) {
|
|
305
315
|
const artifact = desiredState.services[service].artifact;
|
|
@@ -311,7 +321,8 @@ export async function reconcile(desiredState, ctx = defaultContext(), endpoints
|
|
|
311
321
|
await ctx.docker(['compose', '-p', desiredState.project, 'pull', ...(pullServices.length === mainServices.length ? [] : pullServices)], { cwd: dir });
|
|
312
322
|
for (const service of mainServices) {
|
|
313
323
|
serviceStates[service].rolloutState = 'starting';
|
|
314
|
-
|
|
324
|
+
if (announce)
|
|
325
|
+
await emit(service, 'starting');
|
|
315
326
|
}
|
|
316
327
|
if (mainServices.length) {
|
|
317
328
|
// Services that just became rolling still run here until their replacement takes over; keep them.
|
|
@@ -325,14 +336,25 @@ export async function reconcile(desiredState, ctx = defaultContext(), endpoints
|
|
|
325
336
|
}
|
|
326
337
|
for (const service of mainServices) {
|
|
327
338
|
serviceStates[service].rolloutState = 'health_checking';
|
|
328
|
-
|
|
339
|
+
if (announce)
|
|
340
|
+
await emit(service, 'health_checking');
|
|
341
|
+
failing = service;
|
|
329
342
|
await waitForHealth(ctx, desiredState.project, service, desiredState.services[service]?.health, dir);
|
|
343
|
+
failing = undefined;
|
|
330
344
|
serviceStates[service].rolloutState = 'serving';
|
|
331
345
|
serviceStates[service].healthyAt = now();
|
|
332
|
-
|
|
346
|
+
if (announce || wasFailed)
|
|
347
|
+
await emit(service, 'serving', wasFailed ? 'serving again after a failure' : 'serving');
|
|
333
348
|
}
|
|
349
|
+
if (mainServices.length && (announce || wasFailed))
|
|
350
|
+
await markEvents('serving');
|
|
334
351
|
}
|
|
335
352
|
catch (e) {
|
|
353
|
+
if (mainServices.length && !wasFailed) {
|
|
354
|
+
// Announce a failure once; retries of the same failure stay quiet until it recovers or the inputs change.
|
|
355
|
+
await emit(failing ?? mainServices.join(', '), 'failed', errMessage(e), 'error');
|
|
356
|
+
await markEvents('failed');
|
|
357
|
+
}
|
|
336
358
|
// Rolling services are untouched by a failure here, so keep reporting their current revision as serving.
|
|
337
359
|
if (!rolling.length)
|
|
338
360
|
throw e;
|
package/dist/cli.js
CHANGED
|
@@ -311,7 +311,7 @@ program.command('events').description('list recent events with the token that ca
|
|
|
311
311
|
const query = `limit=${encodeURIComponent(o.limit)}${project ? `&project=${encodeURIComponent(project)}` : ''}`;
|
|
312
312
|
console.log(JSON.stringify(await request('GET', `/api/v1/events?${query}`), null, 2));
|
|
313
313
|
});
|
|
314
|
-
program.command('doctor').description('run local and control-plane diagnostics').option('--nodes', 'also
|
|
314
|
+
program.command('doctor').description('run local and control-plane diagnostics').option('--nodes', 'also run diagnostics on every registered agent and wait').option('--node <name>', 'run diagnostics on one agent and wait').option('--timeout <seconds>', 'wait timeout for node diagnostics', '60').action(async (o) => {
|
|
315
315
|
const local = [];
|
|
316
316
|
for (const [name, cmd, args] of [['node', 'node', ['--version']], ['docker', 'docker', ['version', '--format', '{{.Server.Version}}']], ['docker_compose', 'docker', ['compose', 'version']]]) {
|
|
317
317
|
try {
|
|
@@ -323,8 +323,17 @@ program.command('doctor').description('run local and control-plane diagnostics')
|
|
|
323
323
|
}
|
|
324
324
|
const controlDiagnostics = await request('GET', '/api/v1/diagnostics');
|
|
325
325
|
const output = { ok: local.every(c => c.ok) && controlDiagnostics.ok, local, control: controlDiagnostics };
|
|
326
|
-
if (o.nodes || o.node)
|
|
327
|
-
|
|
326
|
+
if (o.nodes || o.node) {
|
|
327
|
+
const queued = await request('POST', '/api/v1/diagnostics/nodes', { node: o.node || null });
|
|
328
|
+
const commands = [];
|
|
329
|
+
for (const command of queued.commands) {
|
|
330
|
+
const finished = await waitForCommand(command.commandId, Number(o.timeout));
|
|
331
|
+
commands.push({ nodeId: command.nodeId, state: finished.state, result: JSON.parse(finished.result_json || '{}') });
|
|
332
|
+
}
|
|
333
|
+
output.nodeDiagnostics = { commands };
|
|
334
|
+
if (commands.some(command => command.state !== 'succeeded'))
|
|
335
|
+
output.ok = false;
|
|
336
|
+
}
|
|
328
337
|
console.log(JSON.stringify(output, null, 2));
|
|
329
338
|
});
|
|
330
339
|
program.command('rollback').option('--force').option('--no-wait', 'return immediately after activating rollback').option('--timeout <seconds>', 'wait timeout', '300').action(async (o) => {
|
package/dist/control.d.ts
CHANGED
|
@@ -19,6 +19,7 @@ export interface ControlImageOperations {
|
|
|
19
19
|
}
|
|
20
20
|
export declare function createApp(options?: {
|
|
21
21
|
images?: Partial<ControlImageOperations>;
|
|
22
|
-
openDatabase?: typeof openDb;
|
|
22
|
+
openDatabase?: typeof openDb; /** Minimum time between event prunes triggered by agent reports; default one hour. */
|
|
23
|
+
eventPruneIntervalMs?: number;
|
|
23
24
|
}): Promise<import("express-serve-static-core").Express>;
|
|
24
25
|
export declare function listen(app: express.Express, port?: number): Promise<import('node:http').Server | https.Server>;
|
package/dist/control.js
CHANGED
|
@@ -21,6 +21,33 @@ import { buildIngressWork, resolveServiceEndpoints } from "./ingress.js";
|
|
|
21
21
|
import { ACTIVE_DEPLOYMENT_STATES } from "./protocol.js";
|
|
22
22
|
import { AUTO_ROLLBACK_ACTOR, ROUTE_ROLES, assertProject, bodyProject, createGuard, createToken, listTokens, parseTokenRequest, queryProject, recordEvent, revokeToken } from "./tokens.js";
|
|
23
23
|
const httpError = (status, message) => Object.assign(new Error(message), { status });
|
|
24
|
+
const missingProject = (project) => `project ${project} has no desired state`;
|
|
25
|
+
const missingService = (project, service) => `service ${service} not found in project ${project}`;
|
|
26
|
+
/** Collapse a node report into the deployment error `tug deploy` prints. Compose failures are multi-line; keep the reason, not the progress log. */
|
|
27
|
+
function deploymentFailureMessage(rows) {
|
|
28
|
+
const limit = 500;
|
|
29
|
+
const oneLine = (value) => {
|
|
30
|
+
const flat = value.replace(/\s+/g, ' ').trim();
|
|
31
|
+
return flat.length > limit ? `${flat.slice(0, limit)}...` : flat;
|
|
32
|
+
};
|
|
33
|
+
const details = rows.map(row => {
|
|
34
|
+
let status = {};
|
|
35
|
+
try {
|
|
36
|
+
status = JSON.parse(row.status_json);
|
|
37
|
+
}
|
|
38
|
+
catch { /* a bad report still names the node */ }
|
|
39
|
+
const parts = [];
|
|
40
|
+
if (typeof status.error === 'string' && status.error.trim())
|
|
41
|
+
parts.push(oneLine(status.error));
|
|
42
|
+
for (const [service, state] of Object.entries(status.serviceStates || {})) {
|
|
43
|
+
if (typeof state?.rolloutError === 'string' && state.rolloutError.trim())
|
|
44
|
+
parts.push(`${service}: ${oneLine(state.rolloutError)}`);
|
|
45
|
+
}
|
|
46
|
+
const detail = parts.join('; ');
|
|
47
|
+
return detail ? `${row.node_id}: ${detail}` : row.node_id;
|
|
48
|
+
});
|
|
49
|
+
return `one or more assigned nodes reported failed health: ${details.join('; ')}`;
|
|
50
|
+
}
|
|
24
51
|
/** Versions of rollback deployments start with this; `tug deploy` may not use it, which is how automatic rollback tells them apart. */
|
|
25
52
|
const ROLLBACK_VERSION_PREFIX = 'rollback-';
|
|
26
53
|
const ACTIVE_STATES_SQL = ACTIVE_DEPLOYMENT_STATES.map(s => `'${s}'`).join(',');
|
|
@@ -90,6 +117,34 @@ export async function createApp(options = {}) {
|
|
|
90
117
|
await reloadRegistry();
|
|
91
118
|
}
|
|
92
119
|
const db = await (options.openDatabase || openDb)(dbFile);
|
|
120
|
+
// Events are an operator-facing log, not a record of truth, so keep them bounded. `0` turns a limit off; anything that is
|
|
121
|
+
// not a whole number falls back to the default.
|
|
122
|
+
const envCount = (name, fallback) => {
|
|
123
|
+
const raw = process.env[name];
|
|
124
|
+
if (raw === undefined || raw === '')
|
|
125
|
+
return fallback;
|
|
126
|
+
const value = Number(raw);
|
|
127
|
+
if (Number.isInteger(value) && value >= 0)
|
|
128
|
+
return value;
|
|
129
|
+
console.warn(`${name}=${JSON.stringify(raw)} is not a whole number; using ${fallback}`);
|
|
130
|
+
return fallback;
|
|
131
|
+
};
|
|
132
|
+
const EVENT_RETENTION_DAYS = envCount('TUG_EVENT_RETENTION_DAYS', 30);
|
|
133
|
+
const EVENT_MAX_ROWS = envCount('TUG_EVENT_MAX_ROWS', 100_000);
|
|
134
|
+
const eventPruneIntervalMs = options.eventPruneIntervalMs ?? 60 * 60 * 1000;
|
|
135
|
+
let lastEventPrune = Date.now();
|
|
136
|
+
async function pruneEvents() {
|
|
137
|
+
lastEventPrune = Date.now();
|
|
138
|
+
let removed = 0;
|
|
139
|
+
if (EVENT_RETENTION_DAYS > 0)
|
|
140
|
+
removed += (await db.run('DELETE FROM events WHERE created_at < ?', new Date(Date.now() - EVENT_RETENTION_DAYS * 86_400_000).toISOString())).changes ?? 0;
|
|
141
|
+
if (EVENT_MAX_ROWS > 0)
|
|
142
|
+
removed += (await db.run('DELETE FROM events WHERE id IN (SELECT id FROM events ORDER BY created_at DESC, id DESC LIMIT -1 OFFSET ?)', EVENT_MAX_ROWS)).changes ?? 0;
|
|
143
|
+
if (removed)
|
|
144
|
+
console.log(`removed ${removed} old events (kept ${EVENT_RETENTION_DAYS || 'any'} days, at most ${EVENT_MAX_ROWS || 'unlimited'} rows)`);
|
|
145
|
+
return removed;
|
|
146
|
+
}
|
|
147
|
+
await pruneEvents().catch(e => console.error(`event pruning failed: ${errMessage(e)}`));
|
|
93
148
|
const masterKey = await loadOrCreateMasterKey(masterKeyFile);
|
|
94
149
|
const operator = await db.get('SELECT value FROM meta WHERE key=?', 'operator_token_hash');
|
|
95
150
|
if (!operator) {
|
|
@@ -184,9 +239,9 @@ export async function createApp(options = {}) {
|
|
|
184
239
|
const statuses = await db.all('SELECT node_id,observed_version,health,status_json FROM node_statuses WHERE project_id=?', projectId);
|
|
185
240
|
const byNode = new Map(statuses.map(s => [s.node_id, s]));
|
|
186
241
|
const reportsRevision = (node) => state.revision ? JSON.parse(byNode.get(node)?.status_json || '{}').observedRevision === state.revision : byNode.get(node)?.observed_version === state.version;
|
|
187
|
-
const
|
|
188
|
-
if (
|
|
189
|
-
const marked = await db.run('UPDATE deployments SET state=?,error_message=?,updated_at=? WHERE id=? AND state=?', 'failed',
|
|
242
|
+
const failedNodes = [...required].filter(node => reportsRevision(node) && byNode.get(node)?.health === 'failed').map(node => byNode.get(node));
|
|
243
|
+
if (failedNodes.length) {
|
|
244
|
+
const marked = await db.run('UPDATE deployments SET state=?,error_message=?,updated_at=? WHERE id=? AND state=?', 'failed', deploymentFailureMessage(failedNodes), now(), project.current_deployment_id, 'rolling_out');
|
|
190
245
|
if (marked.changes === 1)
|
|
191
246
|
scheduleAutoRollback(projectId, project.current_deployment_id, state);
|
|
192
247
|
}
|
|
@@ -877,11 +932,11 @@ export async function createApp(options = {}) {
|
|
|
877
932
|
throw httpError(400, 'service required');
|
|
878
933
|
const ds = await currentDesired(project);
|
|
879
934
|
if (!ds)
|
|
880
|
-
throw httpError(404,
|
|
935
|
+
throw httpError(404, missingProject(project));
|
|
881
936
|
const state = JSON.parse(ds.state_json);
|
|
882
937
|
const svc = state.services?.[service];
|
|
883
938
|
if (!svc)
|
|
884
|
-
throw httpError(404,
|
|
939
|
+
throw httpError(404, missingService(project, service));
|
|
885
940
|
const nodeName = svc.placement?.nodes?.[0];
|
|
886
941
|
if (!nodeName)
|
|
887
942
|
throw httpError(400, 'service has no explicit node placement');
|
|
@@ -912,11 +967,11 @@ export async function createApp(options = {}) {
|
|
|
912
967
|
return res.status(400).json({ error: 'service required' });
|
|
913
968
|
const ds = await currentDesired(project);
|
|
914
969
|
if (!ds)
|
|
915
|
-
return res.status(404).json({ error:
|
|
970
|
+
return res.status(404).json({ error: missingProject(project) });
|
|
916
971
|
const state = JSON.parse(ds.state_json);
|
|
917
972
|
const svc = state.services?.[service];
|
|
918
973
|
if (!svc)
|
|
919
|
-
return res.status(404).json({ error:
|
|
974
|
+
return res.status(404).json({ error: missingService(project, service) });
|
|
920
975
|
const nodeName = svc.placement?.nodes?.[0];
|
|
921
976
|
if (!nodeName)
|
|
922
977
|
return res.status(400).json({ error: 'service has no explicit node placement' });
|
|
@@ -986,6 +1041,8 @@ export async function createApp(options = {}) {
|
|
|
986
1041
|
});
|
|
987
1042
|
api('post', '/api/v1/diagnostics/nodes', null, async (req, res) => {
|
|
988
1043
|
const nodes = req.body.node ? await db.all('SELECT id FROM nodes WHERE id=? OR name=?', req.body.node, req.body.node) : await db.all('SELECT id FROM nodes');
|
|
1044
|
+
if (req.body.node && !nodes.length)
|
|
1045
|
+
return res.status(404).json({ error: `node ${req.body.node} not found` });
|
|
989
1046
|
const ts = now();
|
|
990
1047
|
const commands = [];
|
|
991
1048
|
for (const node of nodes) {
|
|
@@ -1069,8 +1126,14 @@ export async function createApp(options = {}) {
|
|
|
1069
1126
|
await db.run('UPDATE nodes SET last_seen_at=?,status=? WHERE id=?', ts, 'online', req.node.id);
|
|
1070
1127
|
res.json({ ok: true });
|
|
1071
1128
|
});
|
|
1072
|
-
post('/agent/v1/events', authAgent, async (req, res) => {
|
|
1073
|
-
|
|
1129
|
+
post('/agent/v1/events', authAgent, async (req, res) => {
|
|
1130
|
+
const evs = req.body.events || [req.body];
|
|
1131
|
+
for (const ev of evs)
|
|
1132
|
+
await db.run('INSERT INTO events(id,project_id,deployment_id,node_id,level,type,message,metadata_json,created_at) VALUES(?,?,?,?,?,?,?,?,?)', id('evt'), ev.projectId || null, ev.deploymentId || null, req.node.id, ev.level || 'info', ev.type || 'event', ev.message || '', JSON.stringify(ev.metadata || {}), now());
|
|
1133
|
+
if (Date.now() - lastEventPrune >= eventPruneIntervalMs)
|
|
1134
|
+
await pruneEvents().catch(e => console.error(`event pruning failed: ${errMessage(e)}`));
|
|
1135
|
+
res.json({ ok: true });
|
|
1136
|
+
});
|
|
1074
1137
|
get('/agent/v1/secrets', authAgent, async (req, res) => {
|
|
1075
1138
|
const project = String(req.query.project || 'site');
|
|
1076
1139
|
let rows = await db.all('SELECT name,encrypted_value,version FROM secrets WHERE project_id=?', project);
|
package/dist/db.d.ts
CHANGED
|
@@ -105,6 +105,12 @@ export interface LockRow {
|
|
|
105
105
|
expires_at: string;
|
|
106
106
|
created_at: string;
|
|
107
107
|
}
|
|
108
|
+
/** What the agent last announced for a project's recreated services, so unchanged polls stay silent. */
|
|
109
|
+
export interface EventStateRow {
|
|
110
|
+
project: string;
|
|
111
|
+
fingerprint: string;
|
|
112
|
+
phase: 'serving' | 'failed';
|
|
113
|
+
}
|
|
108
114
|
export interface AppliedStateRow {
|
|
109
115
|
project: string;
|
|
110
116
|
version: string;
|
package/dist/db.js
CHANGED
|
@@ -22,6 +22,11 @@ CREATE TABLE IF NOT EXISTS applied_state(
|
|
|
22
22
|
service_states_json TEXT,
|
|
23
23
|
secret_versions_json TEXT
|
|
24
24
|
);
|
|
25
|
+
CREATE TABLE IF NOT EXISTS event_state(
|
|
26
|
+
project TEXT PRIMARY KEY,
|
|
27
|
+
fingerprint TEXT NOT NULL,
|
|
28
|
+
phase TEXT NOT NULL
|
|
29
|
+
);
|
|
25
30
|
`);
|
|
26
31
|
return db;
|
|
27
32
|
}
|
|
@@ -43,6 +48,7 @@ CREATE TABLE IF NOT EXISTS commands(id TEXT PRIMARY KEY, node_id TEXT, project_i
|
|
|
43
48
|
CREATE TABLE IF NOT EXISTS events(id TEXT PRIMARY KEY, project_id TEXT, deployment_id TEXT, node_id TEXT, level TEXT, type TEXT, message TEXT, metadata_json TEXT, created_at TEXT, actor TEXT);
|
|
44
49
|
CREATE INDEX IF NOT EXISTS events_project_created ON events(project_id, created_at);
|
|
45
50
|
CREATE TABLE IF NOT EXISTS api_tokens(id TEXT PRIMARY KEY, name TEXT UNIQUE NOT NULL, token_hash TEXT UNIQUE NOT NULL, role TEXT NOT NULL, projects_json TEXT, created_at TEXT, created_by TEXT, last_used_at TEXT, expires_at TEXT, revoked_at TEXT);
|
|
51
|
+
CREATE INDEX IF NOT EXISTS events_by_time ON events(created_at);
|
|
46
52
|
CREATE TABLE IF NOT EXISTS locks(name TEXT PRIMARY KEY, owner TEXT, expires_at TEXT, created_at TEXT);
|
|
47
53
|
`);
|
|
48
54
|
const row = await db.get('SELECT value FROM meta WHERE key = ?', 'schema_created_at');
|