@tailglow/cli 0.7.1 → 0.9.4

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.
@@ -0,0 +1,181 @@
1
+ import { constants } from "node:fs";
2
+ import { chmod, mkdtemp, open, rm, stat } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { basename, join } from "node:path";
5
+
6
+ /** @param {AbortSignal | undefined} signal */
7
+ function checkAbort(signal) {
8
+ if (signal?.aborted) throw new Error("Import cancelled.");
9
+ }
10
+
11
+ /** @param {string} [message] */
12
+ function changedInput(message = "The input file changed while it was being read.") {
13
+ const error = new Error(message);
14
+ return Object.assign(error, { code: "IMPORT_SOURCE_CHANGED" });
15
+ }
16
+
17
+ /**
18
+ * All passes read one opened descriptor at explicit offsets. The owner closes it after every
19
+ * consumer settles; ending a sampled read never closes or advances a shared stream position.
20
+ *
21
+ * @param {string} file_path
22
+ * @param {{ signal?: AbortSignal }} [options]
23
+ */
24
+ export async function openImportFile(file_path, { signal } = {}) {
25
+ checkAbort(signal);
26
+ const handle = await open(file_path, constants.O_RDONLY | constants.O_NONBLOCK);
27
+ try {
28
+ const initial = await handle.stat({ bigint: true });
29
+ if (!initial.isFile()) throw new Error("Import input must be a regular file.");
30
+ const size = Number(initial.size);
31
+ if (!Number.isSafeInteger(size))
32
+ throw new Error("The input file is too large to address safely.");
33
+ let is_closed = false;
34
+ async function assertUnchanged() {
35
+ checkAbort(signal);
36
+ if (is_closed) throw new Error("The import file reader is closed.");
37
+ const current = await stat(file_path, { bigint: true }).catch(() => null);
38
+ const opened = await handle.stat({ bigint: true });
39
+ if (!current || !sameFile(initial, current) || !sameFile(initial, opened))
40
+ throw changedInput();
41
+ }
42
+ /** @param {number} start @param {number} end */
43
+ async function readRange(start, end) {
44
+ checkAbort(signal);
45
+ if (
46
+ !Number.isSafeInteger(start) ||
47
+ !Number.isSafeInteger(end) ||
48
+ start < 0 ||
49
+ end < start ||
50
+ end > size
51
+ )
52
+ throw new RangeError("The requested file range is outside the input.");
53
+ const bytes = new Uint8Array(end - start);
54
+ for (let offset = 0; offset < bytes.length; ) {
55
+ checkAbort(signal);
56
+ const { bytesRead: count } = await handle.read(
57
+ bytes,
58
+ offset,
59
+ bytes.length - offset,
60
+ start + offset
61
+ );
62
+ if (!count) throw changedInput();
63
+ offset += count;
64
+ }
65
+ return bytes;
66
+ }
67
+ /** @satisfies {import("./import.generated.js").ImportSource} */
68
+ const source = {
69
+ name: basename(file_path),
70
+ size,
71
+ async *open() {
72
+ await assertUnchanged();
73
+ for (let offset = 0; offset < size; ) {
74
+ const end = Math.min(size, offset + 64 * 1024);
75
+ yield await readRange(offset, end);
76
+ offset = end;
77
+ }
78
+ // Run before the row iterator returns, so its final partial batch cannot precede this check.
79
+ await assertUnchanged();
80
+ },
81
+ async read(start, end) {
82
+ await assertUnchanged();
83
+ const bytes = await readRange(start, end);
84
+ await assertUnchanged();
85
+ return bytes;
86
+ }
87
+ };
88
+ await assertUnchanged();
89
+ return {
90
+ source: Object.freeze(source),
91
+ assertUnchanged,
92
+ async cleanup() {
93
+ if (is_closed) return;
94
+ is_closed = true;
95
+ await handle.close();
96
+ }
97
+ };
98
+ } catch (error) {
99
+ await handle.close().catch(() => {});
100
+ throw error;
101
+ }
102
+ }
103
+
104
+ /** @param {import("node:fs").BigIntStats} first @param {import("node:fs").BigIntStats} second */
105
+ function sameFile(first, second) {
106
+ return (
107
+ first.dev === second.dev &&
108
+ first.ino === second.ino &&
109
+ first.size === second.size &&
110
+ first.mtimeNs === second.mtimeNs &&
111
+ first.ctimeNs === second.ctimeNs
112
+ );
113
+ }
114
+
115
+ /**
116
+ * Copy through the opened descriptor and reject replacement or ordinary in-place mutation during
117
+ * copying. The private copy is the sole input to both subsequent passes.
118
+ *
119
+ * @param {string} file_path
120
+ * @param {{ signal?: AbortSignal; temporary_root?: string }} [options]
121
+ */
122
+ export async function snapshotImportFile(file_path, { signal, temporary_root = tmpdir() } = {}) {
123
+ checkAbort(signal);
124
+ const directory = await mkdtemp(join(temporary_root, "tailglow-import-"));
125
+ let original;
126
+ let snapshot;
127
+ let is_complete = false;
128
+ try {
129
+ await chmod(directory, 0o700);
130
+ original = await open(file_path, constants.O_RDONLY | constants.O_NONBLOCK);
131
+ const before = await original.stat({ bigint: true });
132
+ if (!before.isFile()) throw new Error("Import input must be a regular file.");
133
+ const snapshot_path = join(directory, "input");
134
+ snapshot = await open(snapshot_path, "wx", 0o600);
135
+ await snapshot.chmod(0o600);
136
+ const buffer = Buffer.allocUnsafe(64 * 1024);
137
+ let copied_bytes = 0;
138
+ for (;;) {
139
+ checkAbort(signal);
140
+ const { bytesRead: bytes_read } = await original.read(buffer, 0, buffer.length, null);
141
+ if (!bytes_read) break;
142
+ copied_bytes += bytes_read;
143
+ if (BigInt(copied_bytes) > before.size)
144
+ throw changedInput("Input changed while making its private snapshot.");
145
+ await snapshot.writeFile(buffer.subarray(0, bytes_read));
146
+ }
147
+ checkAbort(signal);
148
+ const after = await original.stat({ bigint: true });
149
+ const current = await stat(file_path, { bigint: true });
150
+ if (
151
+ !sameFile(before, after) ||
152
+ !sameFile(before, current) ||
153
+ BigInt(copied_bytes) !== before.size
154
+ ) {
155
+ throw changedInput("Input changed while making its private snapshot.");
156
+ }
157
+ if ((await snapshot.stat({ bigint: true })).size !== before.size)
158
+ throw new Error("The private snapshot size differs from the input file.");
159
+ await snapshot.close();
160
+ snapshot = undefined;
161
+ await original.close();
162
+ original = undefined;
163
+ const input = await openImportFile(snapshot_path, { signal });
164
+ is_complete = true;
165
+ return {
166
+ source: Object.freeze({ ...input.source, name: basename(file_path) }),
167
+ assertUnchanged: input.assertUnchanged,
168
+ async cleanup() {
169
+ try {
170
+ await input.cleanup();
171
+ } finally {
172
+ await rm(directory, { recursive: true, force: true });
173
+ }
174
+ }
175
+ };
176
+ } finally {
177
+ await snapshot?.close().catch(() => {});
178
+ await original?.close().catch(() => {});
179
+ if (!is_complete) await rm(directory, { recursive: true, force: true });
180
+ }
181
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Import flags are separate from generated API fields, but must consume their values at dispatch.
3
+ *
4
+ * @type {Record<string, { type: "string" | "boolean" }>}
5
+ */
6
+ export const IMPORT_PARSER_OPTIONS = {
7
+ format: { type: "string" },
8
+ "record-path": { type: "string" },
9
+ "ingest-url": { type: "string" },
10
+ collection: { type: "string" },
11
+ "project-id": { type: "string" },
12
+ project: { type: "string" },
13
+ "source-id": { type: "string" },
14
+ "ingest-key-id": { type: "string" },
15
+ "api-key": { type: "string" },
16
+ "api-url": { type: "string" },
17
+ team: { type: "string" },
18
+ workers: { type: "string" },
19
+ full: { type: "boolean" },
20
+ "validate-first": { type: "boolean" },
21
+ json: { type: "boolean" },
22
+ help: { type: "boolean" }
23
+ };