@davvidbaker/flambe-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/queue.mjs ADDED
@@ -0,0 +1,161 @@
1
+ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
5
+
6
+ export function defaultQueuePath() {
7
+ return join(homedir(), '.flambe', 'event-queue.json');
8
+ }
9
+
10
+ // How many times a server-*rejected* entry is retried before it is dropped. Only
11
+ // HTTP-status failures count toward this; a server that is simply unreachable
12
+ // never does, so a long offline stretch cannot age out a legitimate event.
13
+ export const MAX_FLUSH_ATTEMPTS = 5;
14
+
15
+ function describeEntry(entry) {
16
+ if (entry.type === 'observe') {
17
+ return `${entry.type} (${entry.input?.kind ?? '?'})`;
18
+ }
19
+ return `${entry.type} (activity ${entry.activityId ?? entry.localId ?? '?'})`;
20
+ }
21
+
22
+ function warnToStderr(message) {
23
+ process.stderr.write(`${message}\n`);
24
+ }
25
+
26
+ function emptyState() {
27
+ return { version: 1, entries: [], aliases: {} };
28
+ }
29
+
30
+ export class FlambeQueue {
31
+ constructor({ baseUrl, traceId, path = defaultQueuePath() }) {
32
+ this.baseUrl = baseUrl;
33
+ this.traceId = Number(traceId);
34
+ this.path = path;
35
+ }
36
+
37
+ matches(entry) {
38
+ return entry.baseUrl === this.baseUrl && entry.traceId === this.traceId;
39
+ }
40
+
41
+ async read() {
42
+ try {
43
+ const state = JSON.parse(await readFile(this.path, 'utf8'));
44
+ if (state?.version !== 1 || !Array.isArray(state.entries) || typeof state.aliases !== 'object') {
45
+ throw new Error(`Invalid Flambe offline queue: ${this.path}`);
46
+ }
47
+ return state;
48
+ } catch (error) {
49
+ if (error?.code === 'ENOENT') return emptyState();
50
+ throw error;
51
+ }
52
+ }
53
+
54
+ async write(state) {
55
+ if (state.entries.length === 0 && Object.keys(state.aliases).length === 0) {
56
+ await rm(this.path, { force: true });
57
+ return;
58
+ }
59
+
60
+ await mkdir(dirname(this.path), { recursive: true, mode: 0o700 });
61
+ const temporaryPath = `${this.path}.${process.pid}.${randomUUID()}.tmp`;
62
+ await writeFile(temporaryPath, `${JSON.stringify(state)}\n`, { mode: 0o600 });
63
+ await rename(temporaryPath, this.path);
64
+ }
65
+
66
+ async enqueue(type, payload) {
67
+ const state = await this.read();
68
+ const localId = type === 'start' ? `offline-${randomUUID()}` : undefined;
69
+ state.entries.push({
70
+ type,
71
+ baseUrl: this.baseUrl,
72
+ traceId: this.traceId,
73
+ ...(localId ? { localId } : {}),
74
+ ...payload,
75
+ });
76
+ await this.write(state);
77
+ return localId;
78
+ }
79
+
80
+ async resolveActivityId(activityId) {
81
+ const state = await this.read();
82
+ return state.aliases[activityId] ?? activityId;
83
+ }
84
+
85
+ async removeAlias(activityId) {
86
+ if (!String(activityId).startsWith('offline-')) return;
87
+ const state = await this.read();
88
+ if (!(activityId in state.aliases)) return;
89
+ delete state.aliases[activityId];
90
+ await this.write(state);
91
+ }
92
+
93
+ async flush({ start, end, suspend, resume, observe }, { warn = warnToStderr } = {}) {
94
+ const state = await this.read();
95
+
96
+ for (let index = 0; index < state.entries.length;) {
97
+ const entry = state.entries[index];
98
+ if (!this.matches(entry)) {
99
+ index += 1;
100
+ continue;
101
+ }
102
+
103
+ try {
104
+ if (entry.type === 'start') {
105
+ const activityId = await start(entry.input);
106
+ state.aliases[entry.localId] = activityId;
107
+ } else if (entry.type === 'end' || entry.type === 'suspend' || entry.type === 'resume') {
108
+ const activityId = state.aliases[entry.activityId] ?? entry.activityId;
109
+ const post = { end, suspend, resume }[entry.type];
110
+ await post({ activityId, message: entry.message, timestamp: entry.timestamp });
111
+ if (entry.type === 'end') delete state.aliases[entry.activityId];
112
+ } else if (entry.type === 'observe') {
113
+ await observe(entry.input);
114
+ } else {
115
+ // An entry type this build does not understand can never succeed, so
116
+ // dropping it is the only way to stop it wedging everything behind it.
117
+ warn(`Flambe: dropping unrecognized offline queue entry type "${entry.type}".`);
118
+ state.entries.splice(index, 1);
119
+ continue;
120
+ }
121
+ } catch (error) {
122
+ // Flushing is best-effort and must never abort the command that triggered
123
+ // it. The three cases below differ only in whether the entry is worth
124
+ // keeping -- but none of them is ever silent, which is the bug this fixes.
125
+ const label = describeEntry(entry);
126
+ const serverResponded = typeof error?.status === 'number';
127
+
128
+ if (error?.retryable && !serverResponded) {
129
+ // Can't reach the server at all -- not this entry's fault. Keep the
130
+ // whole queue in order and try again when connectivity returns.
131
+ const pending = state.entries.length - index;
132
+ warn(`Flambe: offline queue not flushed, server unreachable `
133
+ + `(${pending} pending, first is ${label}): ${error.message}`);
134
+ break;
135
+ }
136
+
137
+ const attempts = (entry.attempts ?? 0) + 1;
138
+ if (error?.retryable && attempts < MAX_FLUSH_ATTEMPTS) {
139
+ // Server reachable but erroring on this entry. Could be a transient
140
+ // 5xx, so keep it and preserve order -- but say so, and bound it.
141
+ entry.attempts = attempts;
142
+ warn(`Flambe: offline queue stalled on ${label} `
143
+ + `(server error, attempt ${attempts}/${MAX_FLUSH_ATTEMPTS}): ${error.message}`);
144
+ break;
145
+ }
146
+
147
+ // A client-side rejection (4xx), or a server error that has recurred too
148
+ // many times to be transient (e.g. a permanently-malformed entry): drop
149
+ // it, loudly and with its payload, so one bad row cannot block the rest.
150
+ warn(`Flambe: dropping offline queue entry ${label} after ${attempts} `
151
+ + `attempt(s): ${error.message}\n payload: ${JSON.stringify(entry)}`);
152
+ state.entries.splice(index, 1);
153
+ continue;
154
+ }
155
+
156
+ state.entries.splice(index, 1);
157
+ }
158
+
159
+ await this.write(state);
160
+ }
161
+ }