dsh-pause-resume 0.1.0 → 0.2.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/lib/snapshot.js CHANGED
@@ -1,93 +1,136 @@
1
- import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeSync } from 'node:fs'
2
- import { join } from 'node:path'
1
+ import * as fs from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
3
 
4
4
  /**
5
- * The one durable fact this plugin owns: a snapshot of the work a press stopped, plus a journal
6
- * of what the following restore already put back.
5
+ * The durable half of the pause button: one plan file, a per-plan restore journal, one archive per
6
+ * consumed plan, and the last restore record.
7
7
  *
8
- * Every write is temp-file + fsync + rename, so a reader never sees half a snapshot and a crash
9
- * leaves the previous complete one in place. Nothing here throws: a disk that cannot be written
10
- * is reported as `null`, and the caller decides to stay in its current phase instead of claiming
11
- * a state it could not record.
8
+ * Every write is temp file + fsync + rename + directory fsync, so a reader never sees half a plan
9
+ * and a crash leaves the previous complete file in place. Nothing here throws: a disk that cannot
10
+ * be written answers `null`, and the caller decides whether to stay in its current phase instead
11
+ * of claiming a state it could not record.
12
12
  */
13
- export function createStore(dir) {
14
- const file = join(dir, 'snapshot.json')
15
- const journal = join(dir, 'restore.jsonl')
16
- const consumed = join(dir, 'last-restore.json')
17
- let ready = false
18
-
19
- function ensure() {
20
- if (ready) return true
13
+
14
+ const UNSAFE = /[^A-Za-z0-9._-]/gu
15
+
16
+ let counter = 0
17
+
18
+ function sanitize(value) {
19
+ const name = String(value ?? '').replace(UNSAFE, '_')
20
+ return name === '' ? '_' : name
21
+ }
22
+
23
+ function closeQuietly(fsv, fd) {
24
+ try {
25
+ fsv.closeSync(fd)
26
+ } catch {}
27
+ }
28
+
29
+ export function createStore(dir, options = {}) {
30
+ const fsv = options.fs ?? fs
31
+ const now = typeof options.now === 'function' ? options.now : Date.now
32
+ const log = typeof options.log === 'function' ? options.log : () => {}
33
+
34
+ const snapshotPath = join(dir, 'snapshot.json')
35
+ const journalDir = join(dir, 'journal')
36
+ const restoresDir = join(dir, 'restores')
37
+ const lastRestorePath = join(dir, 'last-restore.json')
38
+
39
+ const journalPath = (planId) => join(journalDir, `${sanitize(planId)}.jsonl`)
40
+
41
+ /** Flush a directory entry, so the rename that just happened survives a power loss too. */ // ui-copy-ignore
42
+ function syncDir(path) {
43
+ let fd = null
21
44
  try {
22
- mkdirSync(dir, { recursive: true })
23
- ready = true
24
- return true
25
- } catch {
26
- return false
45
+ fd = fsv.openSync(path, 'r')
46
+ fsv.fsyncSync(fd)
47
+ } finally {
48
+ if (fd !== null) closeQuietly(fsv, fd)
27
49
  }
28
50
  }
29
51
 
30
- function read() {
52
+ function atomicWrite(target, value) {
53
+ let body = null
31
54
  try {
32
- const raw = readFileSync(file, 'utf8')
33
- const parsed = JSON.parse(raw)
34
- if (parsed === null || typeof parsed !== 'object') return null
35
- if (parsed.version !== 1) return null
36
- if (parsed.status !== 'paused' && parsed.status !== 'pausing') return null
37
- return parsed
38
- } catch {
39
- return null
55
+ body = JSON.stringify(value)
56
+ } catch (error) {
57
+ log(`cannot serialize ${target}: ${error?.message ?? error}`)
58
+ return false
40
59
  }
41
- }
42
-
43
- function write(value) {
44
- if (!ensure()) return null
45
- const body = JSON.stringify({ ...value, version: 1 })
46
- const tmp = `${file}.${process.pid}.tmp`
60
+ const tmp = `${target}.${process.pid}.${counter++}.tmp`
47
61
  let fd = null
62
+ let renamed = false
48
63
  try {
49
- fd = openSync(tmp, 'w')
50
- writeSync(fd, body)
51
- fsyncSync(fd)
52
- closeSync(fd)
64
+ fsv.mkdirSync(dirname(target), { recursive: true })
65
+ fd = fsv.openSync(tmp, 'w')
66
+ fsv.writeSync(fd, body)
67
+ fsv.fsyncSync(fd)
68
+ const opened = fd
53
69
  fd = null
54
- renameSync(tmp, file)
55
- return value
56
- } catch {
57
- if (fd !== null) {
70
+ fsv.closeSync(opened)
71
+ fsv.renameSync(tmp, target)
72
+ renamed = true
73
+ syncDir(dirname(target))
74
+ return true
75
+ } catch (error) {
76
+ log(`cannot write ${target}: ${error?.message ?? error}`)
77
+ return false
78
+ } finally {
79
+ if (fd !== null) closeQuietly(fsv, fd)
80
+ if (renamed === false) {
58
81
  try {
59
- closeSync(fd)
82
+ fsv.rmSync(tmp, { force: true })
60
83
  } catch {}
61
84
  }
62
- try {
63
- rmSync(tmp, { force: true })
64
- } catch {}
85
+ }
86
+ }
87
+
88
+ function read() {
89
+ let parsed = null
90
+ try {
91
+ parsed = JSON.parse(fsv.readFileSync(snapshotPath, 'utf8'))
92
+ } catch {
65
93
  return null
66
94
  }
95
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null
96
+ if (parsed.version !== 2) return null
97
+ if (parsed.status !== 'pausing' && parsed.status !== 'paused') return null
98
+ return parsed
99
+ }
100
+
101
+ function write(plan) {
102
+ if (plan === null || typeof plan !== 'object' || Array.isArray(plan)) return null
103
+ const record = { ...plan, version: 2 }
104
+ return atomicWrite(snapshotPath, record) ? record : null
67
105
  }
68
106
 
69
- /** The snapshot is spent: the same press must not restore the same work twice. */
70
- function consume(summary) {
71
- if (!ensure()) return null
107
+ function appendJournal(planId, key) {
108
+ let line = null
72
109
  try {
73
- const current = read()
74
- const record = current === null ? { status: 'restored' } : { ...current, status: 'restored', restoredAt: new Date().toISOString(), summary }
75
- const tmp = `${consumed}.${process.pid}.tmp`
76
- writeSync(openSync(tmp, 'w'), JSON.stringify(record))
77
- renameSync(tmp, consumed)
78
- } catch {}
110
+ line = JSON.stringify({ k: key })
111
+ } catch {
112
+ return false
113
+ }
114
+ let fd = null
79
115
  try {
80
- rmSync(file, { force: true })
81
- } catch {}
82
- return summary
116
+ fsv.mkdirSync(journalDir, { recursive: true })
117
+ fd = fsv.openSync(journalPath(planId), 'a')
118
+ fsv.writeSync(fd, `${line}\n`)
119
+ fsv.fsyncSync(fd)
120
+ return true
121
+ } catch (error) {
122
+ log(`cannot journal ${planId}: ${error?.message ?? error}`)
123
+ return false
124
+ } finally {
125
+ if (fd !== null) closeQuietly(fsv, fd)
126
+ }
83
127
  }
84
128
 
85
- /** Journals are append-only lines of `{"k":"s:<id>"}`; a torn last line is dropped on read. */
86
- function readJournal() {
129
+ function readJournal(planId) {
87
130
  const done = new Set()
88
131
  let raw = ''
89
132
  try {
90
- raw = readFileSync(journal, 'utf8')
133
+ raw = fsv.readFileSync(journalPath(planId), 'utf8')
91
134
  } catch {
92
135
  return done
93
136
  }
@@ -96,38 +139,60 @@ export function createStore(dir) {
96
139
  try {
97
140
  const row = JSON.parse(line)
98
141
  if (typeof row?.k === 'string') done.add(row.k)
99
- } catch {}
142
+ } catch {
143
+ // A torn last line is a write that never finished, not a key that was restored.
144
+ }
100
145
  }
101
146
  return done
102
147
  }
103
148
 
104
- function appendJournal(key) {
105
- if (!ensure()) return false
149
+ function clearJournal(planId) {
106
150
  try {
107
- writeSync(openSync(journal, 'a'), `${JSON.stringify({ k: key })}\n`)
108
- return true
109
- } catch {
110
- return false
111
- }
151
+ fsv.rmSync(journalPath(planId), { force: true })
152
+ } catch {}
112
153
  }
113
154
 
114
- function clearJournal() {
115
- try {
116
- rmSync(journal, { force: true })
117
- } catch {}
155
+ /**
156
+ * The plan is spent: archive it, publish the record the UI reads, and only then remove it. A plan
157
+ * that could not be archived stays on disk, so the same work is never claimed twice.
158
+ */
159
+ function consume(plan, summary) {
160
+ const source = plan !== null && typeof plan === 'object' && !Array.isArray(plan) ? plan : {}
161
+ const archivePath = join(restoresDir, `${sanitize(source.planId)}.json`)
162
+ const record = { ...source, status: 'restored', restoredAt: new Date(now()).toISOString(), summary }
163
+ const archived = atomicWrite(archivePath, record)
164
+ const published = archived && atomicWrite(lastRestorePath, record)
165
+ if (published === false) {
166
+ log('cannot archive the restored plan: the snapshot stays')
167
+ return { ok: false, archivePath: null }
168
+ }
169
+ const current = read()
170
+ // Another press may have written a newer plan while this one restored; that one is not ours.
171
+ if (current !== null && current.planId === source.planId) {
172
+ try {
173
+ fsv.rmSync(snapshotPath, { force: true })
174
+ } catch {}
175
+ }
176
+ clearJournal(source.planId)
177
+ return { ok: true, archivePath }
118
178
  }
119
179
 
120
180
  function lastRestore() {
121
181
  try {
122
- return JSON.parse(readFileSync(consumed, 'utf8'))
182
+ const parsed = JSON.parse(fsv.readFileSync(lastRestorePath, 'utf8'))
183
+ return parsed !== null && typeof parsed === 'object' ? parsed : null
123
184
  } catch {
124
185
  return null
125
186
  }
126
187
  }
127
188
 
128
189
  function hasSnapshot() {
129
- return existsSync(file)
190
+ try {
191
+ return fsv.existsSync(snapshotPath) === true
192
+ } catch {
193
+ return false
194
+ }
130
195
  }
131
196
 
132
- return { dir, file, read, write, consume, readJournal, appendJournal, clearJournal, lastRestore, hasSnapshot }
197
+ return { dir, snapshotPath, read, write, appendJournal, readJournal, clearJournal, consume, lastRestore, hasSnapshot }
133
198
  }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "dsh-pause-resume",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
- "description": "One header button stops every running turn and goal, writes a snapshot, and puts the same work back on the next boot; the button's shape is the only status.",
5
+ "description": "One header button stops running turns, goals and jobs, writes a plan to disk, and restores that work after a restart or crash; the button shape is the only status.",
6
6
  "license": "MIT",
7
7
  "exports": {
8
8
  ".": "./index.js",