@nyc-transit-kit/cli 0.1.0 → 0.1.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nyc-transit-kit/cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "bin": {
@@ -23,13 +23,13 @@
23
23
  "test": "bun test ./test"
24
24
  },
25
25
  "dependencies": {
26
- "@effect/platform-bun": "4.0.0-beta.83",
27
- "@nyc-transit-kit/contracts": "0.1.0",
28
- "@nyc-transit-kit/mta": "0.1.0",
29
- "@nyc-transit-kit/nyc-dot": "0.1.0",
30
- "@nyc-transit-kit/nyc-open-data": "0.1.0",
31
- "@nyc-transit-kit/soda3": "0.1.0",
32
- "effect": "4.0.0-beta.83"
26
+ "@effect/platform-bun": "4.0.0-beta.92",
27
+ "@nyc-transit-kit/contracts": "0.1.2",
28
+ "@nyc-transit-kit/mta": "0.1.2",
29
+ "@nyc-transit-kit/nyc-dot": "0.1.2",
30
+ "@nyc-transit-kit/nyc-open-data": "0.1.2",
31
+ "@nyc-transit-kit/soda3": "0.1.2",
32
+ "effect": "4.0.0-beta.92"
33
33
  },
34
34
  "publishConfig": {
35
35
  "access": "public"
@@ -19,7 +19,7 @@ import * as Effect from "effect/Effect"
19
19
  import * as Option from "effect/Option"
20
20
  import * as Command from "effect/unstable/cli/Command"
21
21
  import * as Flag from "effect/unstable/cli/Flag"
22
- import { atomicWrite } from "../files"
22
+ import { atomicWriteGroup } from "../files"
23
23
  import { failCommand, missingOption, runEffect, soqlFromSelect, writeSuccess } from "./shared"
24
24
  import { fileWriteError, writeResponseToFile, writeSoda3QueryDryRun } from "./soda3-shared"
25
25
  import { CliCommandContext, type CommandContext } from "./types"
@@ -436,11 +436,17 @@ export const handleMtaGtfsRealtimeCapture = (config: {
436
436
  })
437
437
 
438
438
  yield* Effect.tryPromise({
439
- try: () => atomicWrite(config.output, result.bytes),
440
- catch: fileWriteError
441
- })
442
- yield* Effect.tryPromise({
443
- try: () => atomicWrite(config.manifestOutput, `${JSON.stringify(manifest, null, 2)}\n`),
439
+ try: () =>
440
+ atomicWriteGroup([
441
+ {
442
+ path: config.output,
443
+ body: result.bytes
444
+ },
445
+ {
446
+ path: config.manifestOutput,
447
+ body: `${JSON.stringify(manifest, null, 2)}\n`
448
+ }
449
+ ]),
444
450
  catch: fileWriteError
445
451
  })
446
452
 
package/src/files.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { rename } from "node:fs/promises"
1
+ import { lstat, rename, rm } from "node:fs/promises"
2
2
 
3
3
  export type AtomicWriteBody =
4
4
  | Blob
@@ -8,20 +8,94 @@ export type AtomicWriteBody =
8
8
  | Bun.Archive
9
9
  | Response
10
10
 
11
- export const atomicWrite = async (path: string, body: AtomicWriteBody) => {
12
- const tempPath = `${path}.tmp-${process.pid}`
11
+ export type AtomicWriteEntry = {
12
+ readonly path: string
13
+ readonly body: AtomicWriteBody
14
+ }
15
+
16
+ type StagedWrite = AtomicWriteEntry & {
17
+ readonly tempPath: string
18
+ readonly backupPath: string
19
+ backedUp: boolean
20
+ committed: boolean
21
+ }
22
+
23
+ const writeBody = async (path: string, body: AtomicWriteBody) => {
24
+ if (body instanceof Response) {
25
+ await Bun.file(path).write(body)
26
+ } else {
27
+ await Bun.write(path, body)
28
+ }
29
+ }
30
+
31
+ const removeFile = (path: string) => rm(path, { force: true }).catch(() => undefined)
32
+
33
+ const assertUniqueTargets = (entries: ReadonlyArray<AtomicWriteEntry>) => {
34
+ const seen = new Set<string>()
35
+ for (const entry of entries) {
36
+ if (seen.has(entry.path)) {
37
+ throw new Error(`Duplicate atomic write target: ${entry.path}`)
38
+ }
39
+ seen.add(entry.path)
40
+ }
41
+ }
42
+
43
+ const backupExistingTarget = async (write: StagedWrite) => {
44
+ try {
45
+ const stat = await lstat(write.path)
46
+ if (stat.isDirectory()) {
47
+ throw new Error(`Atomic write target is a directory: ${write.path}`)
48
+ }
49
+ await rename(write.path, write.backupPath)
50
+ write.backedUp = true
51
+ } catch (cause) {
52
+ if (typeof cause === "object" && cause !== null && "code" in cause && cause.code === "ENOENT") {
53
+ return
54
+ }
55
+ throw cause
56
+ }
57
+ }
58
+
59
+ export const atomicWriteGroup = async (entries: ReadonlyArray<AtomicWriteEntry>) => {
60
+ assertUniqueTargets(entries)
61
+ const batchId = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`
62
+ const writes = entries.map((entry, index) => ({
63
+ ...entry,
64
+ tempPath: `${entry.path}.tmp-${batchId}-${index}`,
65
+ backupPath: `${entry.path}.bak-${batchId}-${index}`,
66
+ backedUp: false,
67
+ committed: false
68
+ }))
13
69
 
14
70
  try {
15
- if (body instanceof Response) {
16
- await Bun.file(tempPath).write(body)
17
- } else {
18
- await Bun.write(tempPath, body)
71
+ for (const write of writes) {
72
+ await writeBody(write.tempPath, write.body)
73
+ }
74
+
75
+ for (const write of writes) {
76
+ await backupExistingTarget(write)
77
+ await rename(write.tempPath, write.path)
78
+ write.committed = true
79
+ }
80
+
81
+ for (const write of writes) {
82
+ if (write.backedUp) {
83
+ await removeFile(write.backupPath)
84
+ }
19
85
  }
20
- await rename(tempPath, path)
21
86
  } catch (cause) {
22
- await Bun.file(tempPath)
23
- .delete()
24
- .catch(() => undefined)
87
+ for (const write of writes.toReversed()) {
88
+ if (write.committed) {
89
+ await removeFile(write.path)
90
+ }
91
+ if (write.backedUp) {
92
+ await rename(write.backupPath, write.path).catch(() => undefined)
93
+ }
94
+ await removeFile(write.tempPath)
95
+ }
25
96
  throw cause
26
97
  }
27
98
  }
99
+
100
+ export const atomicWrite = async (path: string, body: AtomicWriteBody) =>
101
+ atomicWriteGroup([{ path, body }])