Repository navigation
Expand file tree
/
Copy pathpending-store.ts
More file actions
120 lines (105 loc) · 3.07 KB
/
Copy pathpending-store.ts
File metadata and controls
120 lines (105 loc) · 3.07 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
import {
mkdirSync,
readFileSync,
renameSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { dirname, join } from 'node:path';
import { z } from 'zod';
import { amplitudeDataFiles, amplitudeDataPath } from './amplitude-paths';
export const CURRENT_PENDING_VERSION = 1;
const pendingEntrySchema = z
.object({
device_code: z.string().min(1),
base_url: z.string().min(1),
expires_at: z.string().min(1),
interval: z.number().int().positive(),
started_at: z.string().min(1),
})
.catchall(z.unknown());
const pendingStoreSchema = z
.object({
version: z.number().int(),
pending: z.record(z.string(), pendingEntrySchema),
})
.catchall(z.unknown());
export type PendingEntry = z.infer<typeof pendingEntrySchema>;
export type PendingStore = z.infer<typeof pendingStoreSchema>;
export function pendingPath(override?: string): string {
return amplitudeDataPath(amplitudeDataFiles.pendingLogins, override);
}
export function emptyPending(): PendingStore {
return { version: CURRENT_PENDING_VERSION, pending: {} };
}
// Pending entries are disposable: any unreadable / unparseable / schema-mismatched
// file is treated as "nothing in flight" rather than surfaced as an error.
export function loadPending(path: string = pendingPath()): PendingStore {
let raw: string;
try {
raw = readFileSync(path, 'utf8');
} catch {
return emptyPending();
}
try {
const parsed = pendingStoreSchema.safeParse(JSON.parse(raw));
return parsed.success ? parsed.data : emptyPending();
} catch {
return emptyPending();
}
}
export function savePending(
store: PendingStore,
path: string = pendingPath(),
): void {
const dir = dirname(path);
mkdirSync(dir, { recursive: true, mode: 0o700 });
const tempPath = join(
dir,
`.pending-logins.${process.pid}.${Date.now()}.tmp`,
);
try {
writeFileSync(tempPath, `${JSON.stringify(store, null, 2)}\n`, {
mode: 0o600,
});
renameSync(tempPath, path);
} catch (error) {
rmSync(tempPath, { force: true });
throw error;
}
}
export function getPending(
store: PendingStore,
name: string,
): PendingEntry | undefined {
return store.pending[name];
}
export function setPending(
store: PendingStore,
name: string,
entry: PendingEntry,
): PendingStore {
return { ...store, pending: { ...store.pending, [name]: entry } };
}
export function removePending(store: PendingStore, name: string): PendingStore {
const pending = { ...store.pending };
delete pending[name];
return { ...store, pending };
}
export function isPendingExpired(entry: PendingEntry, now: number): boolean {
const at = Date.parse(entry.expires_at);
return Number.isNaN(at) ? true : at <= now;
}
/** Returns a copy of the store with expired entries dropped (opportunistic GC). */
export function gcExpiredPending(
store: PendingStore,
now: number,
): PendingStore {
const pending: Record<string, PendingEntry> = {};
for (const [name, entry] of Object.entries(store.pending)) {
if (!isPendingExpired(entry, now)) {
pending[name] = entry;
}
}
return { ...store, pending };
}