@live-change/backup-service 0.9.208 → 0.9.212

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.
Files changed (4) hide show
  1. package/backup.js +118 -46
  2. package/clear.js +31 -0
  3. package/index.js +134 -32
  4. package/package.json +5 -5
package/backup.js CHANGED
@@ -9,6 +9,7 @@ import { exec as cpExec } from 'child_process'
9
9
  const exec = util.promisify(cpExec)
10
10
  import dump from '@live-change/db-client/lib/dump.js'
11
11
  import { once } from 'events'
12
+ import { finished } from 'node:stream/promises'
12
13
  import os from "os"
13
14
  import path from 'path'
14
15
 
@@ -17,6 +18,14 @@ function currentBackupPath(backupsDir = './backups/') {
17
18
  return `${backupsDir}/${dateString.substring(0, dateString.length - 1)}`
18
19
  }
19
20
 
21
+ function createBackupLogger(backupsDir) {
22
+ const logPath = path.join(path.resolve(backupsDir), 'backups.log')
23
+ return async function log(message) {
24
+ const line = `${new Date().toISOString()} ${message}\n`
25
+ await fs.promises.appendFile(logPath, line, { encoding: 'utf8' })
26
+ }
27
+ }
28
+
20
29
  async function writeDbBackup(stream) {
21
30
  let drainPromise
22
31
  async function write(object) {
@@ -27,11 +36,10 @@ async function writeDbBackup(stream) {
27
36
  drainPromise = null
28
37
  }
29
38
  }
30
- await dump({
39
+ try {
40
+ await dump({
31
41
  dao: app.dao,
32
42
  db: app.databaseName,
33
- //serverUrl: process.env.DB_URL || 'http://localhost:9417/api/ws',
34
- //db: process.env.DB_NAME || 'test',
35
43
  structure: true,
36
44
  verbose: true,
37
45
  bucket: 128, // less database stress
@@ -39,64 +47,128 @@ async function writeDbBackup(stream) {
39
47
  },
40
48
  (method, ...args) => write({ type: 'request', method, parameters: args }),
41
49
  () => write({ type: 'sync' })
42
- )
43
- stream.end()
50
+ )
51
+ stream.end()
52
+ await finished(stream)
53
+ } catch (err) {
54
+ stream.destroy()
55
+ throw err
56
+ }
44
57
  }
45
58
 
46
- async function createBackup(backupPath = currentBackupPath()) {
47
- await fs.promises.mkdir(backupPath)
48
-
49
- const dbStream = fs.createWriteStream(path.resolve(backupPath, 'db.json'))
50
- await writeDbBackup(dbStream)
51
-
52
- const version = await (
53
- fs.promises.readFile('./package.json', { encoding: 'utf8' })
54
- .catch(e => 'unknown')
55
- .then(data => JSON.parse(data).version)
56
- )
57
- const info = {
58
- version: version,
59
- hostname: os.hostname(),
60
- directory: path.resolve('.')
61
- }
59
+ function parseBackupTimestamp(basename) {
60
+ const match = basename.match(/^(\d{4})_(\d{2})_(\d{2})_(\d{2})_(\d{2})_(\d{2})_(\d+)/)
61
+ if(!match) return null
62
+ const [_full, year, month, day, hour, minute, second, millis] = match
63
+ return new Date(`${year}-${month}-${day}T${hour}:${minute}:${second}.${millis}Z`)
64
+ }
65
+
66
+ async function createBackup(backupPath, log) {
67
+ const resolvedPath = path.resolve(backupPath)
68
+ const parentDir = path.dirname(resolvedPath)
69
+ const base = path.basename(resolvedPath)
70
+ const tarTmpPath = path.join(parentDir, `${base}.tar.gz.tmp`)
71
+
72
+ await log(`createBackup start backupPath=${resolvedPath}`)
73
+
74
+ try {
75
+ await fs.promises.mkdir(resolvedPath)
76
+ await log('mkdir done')
77
+
78
+ const dbStream = fs.createWriteStream(path.resolve(resolvedPath, 'db.json'))
79
+ await writeDbBackup(dbStream)
80
+ await log('db dump finished (stream closed)')
81
+
82
+ const version = await (
83
+ fs.promises.readFile('./package.json', { encoding: 'utf8' })
84
+ .catch(e => 'unknown')
85
+ .then(data => JSON.parse(data).version)
86
+ )
87
+ const info = {
88
+ version: version,
89
+ hostname: os.hostname(),
90
+ directory: path.resolve('.')
91
+ }
92
+
93
+ await fs.promises.writeFile(path.resolve(resolvedPath, 'info.json'), JSON.stringify(info, null, ' '))
94
+ await log('info.json written')
62
95
 
63
- await fs.promises.writeFile(path.resolve(backupPath, 'info.json'), JSON.stringify(info, null, ' '))
96
+ await fse.copy("./storage", path.resolve(resolvedPath, "storage"))
97
+ await log('storage copied')
64
98
 
65
- await fse.copy("./storage", path.resolve(backupPath, "storage"))
99
+ const command = `tar -zcf ../${base}.tar.gz.tmp storage info.json db.json`
100
+ console.log("EXEC TAR COMMAND:", command)
101
+ await log(`tar start command=${command}`)
102
+ await exec(command, { cwd: resolvedPath })
103
+ await log('tar done')
66
104
 
67
- const command = `tar -zcf ../${path.basename(backupPath)}.tar.gz.tmp storage info.json db.json`
68
- console.log("EXEC TAR COMMAND:", command)
69
- await exec(command, { cwd: backupPath })
105
+ await fs.promises.rename(tarTmpPath, path.join(parentDir, `${base}.tar.gz`))
106
+ await log('archive renamed to .tar.gz')
70
107
 
71
- await fs.promises.rename(backupPath + '.tar.gz.tmp', backupPath + '.tar.gz')
108
+ await fse.remove(resolvedPath)
109
+ await log('temp directory removed')
72
110
 
73
- await fse.remove(backupPath)
111
+ await log(`createBackup success backupPath=${resolvedPath}`)
112
+ } catch (err) {
113
+ const detail = err && err.stack ? err.stack : String(err)
114
+ await log(`createBackup error: ${detail}`)
115
+ try {
116
+ if(await fse.pathExists(resolvedPath)) {
117
+ await fse.remove(resolvedPath)
118
+ await log(`cleanup removed temp directory ${resolvedPath}`)
119
+ }
120
+ } catch (cleanupErr) {
121
+ await log(`cleanup temp directory failed: ${cleanupErr}`)
122
+ }
123
+ try {
124
+ if(await fse.pathExists(tarTmpPath)) {
125
+ await fse.remove(tarTmpPath)
126
+ await log(`cleanup removed ${tarTmpPath}`)
127
+ }
128
+ } catch (cleanupErr) {
129
+ await log(`cleanup tar tmp failed: ${cleanupErr}`)
130
+ }
131
+ throw err
132
+ }
74
133
  }
75
134
 
76
- async function removeOldBackups(backupsDir = '../../backups/', maxAge = 10*24*3600*1000, minBackups = 10) {
77
- const backupFiles = await fs.promises.readdir(backupsDir)
135
+ async function removeOldBackups(backupsDir = '../../backups/', maxAge = 10*24*3600*1000, minBackups = 10, log) {
136
+ const resolvedDir = path.resolve(backupsDir)
137
+ const dirents = await fs.promises.readdir(resolvedDir, { withFileTypes: true })
78
138
  const now = Date.now()
79
- // read backup times from filenames
80
- const backups = backupFiles.map(file => {
81
- const match = file.match(/(\d{4})_(\d{2})_(\d{2})_(\d{2})_(\d{2})_(\d{2})_(\d+)\.tar\.gz/)
82
- if(!match) {
83
- console.error("BACKUP FILE NAME DOES NOT MATCH:", file)
84
- return null
85
- }
86
- const [_full, year, month, day, hour, minute, second, millis] = match
87
- const date = new Date(`${year}-${month}-${day}T${hour}:${minute}:${second}.${millis}Z`)
88
- return { file, date }
89
- }).filter(backup => backup !== null)
90
- // sort by date, from oldest to newest
139
+
140
+ const backups = []
141
+ for(const dirent of dirents) {
142
+ if(!dirent.isFile()) continue
143
+ const file = dirent.name
144
+ if(!file.endsWith('.tar.gz')) continue
145
+ const date = parseBackupTimestamp(file.replace(/\.tar\.gz$/, ''))
146
+ if(!date || Number.isNaN(date.getTime())) continue
147
+ backups.push({ file, date })
148
+ }
149
+
91
150
  backups.sort((a, b) => a.date - b.date)
92
151
  const olderBackups = backups
93
- .slice(0, backups.length - minBackups) // leave at least minBackups
94
- .filter(backup => now - backup.date > maxAge) // leave only backups older than maxAge
152
+ .slice(0, backups.length - minBackups)
153
+ .filter(backup => now - backup.date > maxAge)
95
154
  for(const backup of olderBackups) {
96
- const backupPath = path.resolve(backupsDir, backup.file)
155
+ const backupPath = path.join(resolvedDir, backup.file)
97
156
  console.log("REMOVING OLD BACKUP:", backupPath)
157
+ if(log) await log(`removeOldBackups removing archive ${backupPath}`)
98
158
  await fse.remove(backupPath)
99
159
  }
160
+
161
+ for(const dirent of dirents) {
162
+ if(!dirent.isDirectory()) continue
163
+ const name = dirent.name
164
+ const date = parseBackupTimestamp(name)
165
+ if(!date || Number.isNaN(date.getTime())) continue
166
+ if(now - date <= maxAge) continue
167
+ const dirPath = path.join(resolvedDir, name)
168
+ console.log("REMOVING OLD BACKUP DIRECTORY:", dirPath)
169
+ if(log) await log(`removeOldBackups removing stale directory ${dirPath}`)
170
+ await fse.remove(dirPath)
171
+ }
100
172
  }
101
173
 
102
- export { createBackup, currentBackupPath, removeOldBackups }
174
+ export { createBackup, createBackupLogger, currentBackupPath, removeOldBackups }
package/clear.js CHANGED
@@ -97,4 +97,35 @@ export async function removeOldCommands(before, options) {
97
97
  if(options.delay) await new Promise(resolve => setTimeout(resolve, options.delay))
98
98
  more = deleteStats.count >= bucket
99
99
  }
100
+ }
101
+
102
+ const clearEventReportsQuery = `${async (input, output, { indexName, idPrefix, bucket, before }) => {
103
+ const indexReader = await input.index(indexName)
104
+ const tableWriter = await output.table('eventReports')
105
+ const pointers = await indexReader.rangeGet({ limit: bucket, lt: before })
106
+ for(const pointer of pointers) {
107
+ await tableWriter.delete(idPrefix + pointer.to)
108
+ }
109
+ await output.put({ count: pointers.length })
110
+ }}`
111
+
112
+ async function removeOldEventReportsForIndex(indexName, idPrefix, before, options) {
113
+ const bucket = options?.bucket || 128
114
+ let more = true
115
+ while(more) {
116
+ const queryResult = await app.dao.request(['database', 'query'], app.databaseName, clearEventReportsQuery,
117
+ { indexName, idPrefix, bucket, before })
118
+ const deleteStats = queryResult[0]
119
+ if(options?.delay) await new Promise(resolve => setTimeout(resolve, options.delay))
120
+ more = deleteStats.count >= bucket
121
+ }
122
+ }
123
+
124
+ export async function removeOldEventReports(commandBefore, triggerBefore, options = {}) {
125
+ if(!app.splitCommands && commandBefore) {
126
+ await removeOldEventReportsForIndex('commands_byTimestamp', 'command_', commandBefore, options)
127
+ }
128
+ if(!app.splitTriggers && triggerBefore) {
129
+ await removeOldEventReportsForIndex('triggers_byTimestamp', 'trigger_', triggerBefore, options)
130
+ }
100
131
  }
package/index.js CHANGED
@@ -8,17 +8,19 @@ import express from 'express'
8
8
  import basicAuth from 'express-basic-auth'
9
9
  const expressApp = express()
10
10
  import fs from 'fs'
11
- import { createBackup, currentBackupPath, removeOldBackups } from './backup.js'
11
+ import { createBackup, createBackupLogger, currentBackupPath, removeOldBackups } from './backup.js'
12
12
  import { restoreBackup } from './restore.js'
13
13
  import {
14
14
  getLastEventId, removeOldEvents, removeOldOpLogs,
15
- getLastCommandTimestamp, getLastTriggerTimestamp, removeOldTriggers, removeOldCommands
15
+ getLastCommandTimestamp, getLastTriggerTimestamp, removeOldTriggers, removeOldCommands,
16
+ removeOldEventReports
16
17
  } from './clear.js'
17
18
 
18
19
  import progress from "progress-stream"
19
20
 
20
21
  const TWENTY_FOUR_HOURS = 24 * 3600 * 1000
21
22
  const TEN_MINUTES = 10 * 60 * 1000
23
+ const DEFAULT_OP_LOG_RETENTION_MS = 60 * 60 * 1000
22
24
 
23
25
  let currentBackup = null
24
26
 
@@ -30,6 +32,8 @@ const backupsDir = config.dir || './backups'
30
32
 
31
33
  fs.mkdirSync(backupsDir, { recursive: true })
32
34
 
35
+ const backupLog = createBackupLogger(backupsDir)
36
+
33
37
  let queue
34
38
  (async () => {
35
39
  const PQueue = (await import('p-queue')).default
@@ -41,7 +45,7 @@ if(backupServerUsername && backupServerPassword ) {
41
45
  users[backupServerUsername] = backupServerPassword
42
46
  }
43
47
 
44
- if(Object.keys(users) > 0) {
48
+ if(Object.keys(users).length > 0) {
45
49
  expressApp.use(basicAuth({
46
50
  users,
47
51
  challenge: true,
@@ -49,6 +53,87 @@ if(Object.keys(users) > 0) {
49
53
  }))
50
54
  }
51
55
 
56
+ function resolveRetentionMs(option) {
57
+ const value = config[option]
58
+ if(Number.isInteger(value)) return value
59
+ if(value === true && Number.isInteger(config.retentionMs)) return config.retentionMs
60
+ if(value !== false && Number.isInteger(config.retentionMs)) return config.retentionMs
61
+ return null
62
+ }
63
+
64
+ function shouldClear(option) {
65
+ const value = config[option]
66
+ if(value === false) return false
67
+ if(Number.isInteger(value)) return true
68
+ if(value === true) return true
69
+ if(Number.isInteger(config.retentionMs)) return true
70
+ return false
71
+ }
72
+
73
+ function resolveOpLogRetentionMs() {
74
+ if(Number.isInteger(config.opLogRetentionMs)) return config.opLogRetentionMs
75
+ if(config.clearOpLogs === true && Number.isInteger(config.retentionMs)) return config.retentionMs
76
+ if(Number.isInteger(config.clearOpLogs)) return config.clearOpLogs
77
+ return DEFAULT_OP_LOG_RETENTION_MS
78
+ }
79
+
80
+ function resolveEventReportsRetentionMs() {
81
+ if(Number.isInteger(config.clearEventReports)) return config.clearEventReports
82
+ return resolveRetentionMs('clearEventReports')
83
+ ?? resolveRetentionMs('clearCommands')
84
+ ?? resolveRetentionMs('clearTriggers')
85
+ }
86
+
87
+ function computeTimestampCutoff(lastTimestamp, retentionMs) {
88
+ if(retentionMs === null) return null
89
+ const date = new Date(lastTimestamp)
90
+ if(!Number.isInteger(date.getTime())) return null
91
+ return new Date(date.getTime() - retentionMs).toISOString()
92
+ }
93
+
94
+ function computeCutoffs({ lastEventId, lastTriggerTimestamp, lastCommandTimestamp }) {
95
+ const eventsRetention = resolveRetentionMs('clearEvents')
96
+ let eventsBefore = lastEventId
97
+ if(shouldClear('clearEvents') && eventsRetention !== null && lastEventId) {
98
+ const ts = (+(lastEventId.split(':')[0]) - eventsRetention)
99
+ eventsBefore = ts.toFixed(0).padStart(16, '0') + ':'
100
+ } else if(!shouldClear('clearEvents')) {
101
+ eventsBefore = null
102
+ }
103
+
104
+ const triggersRetention = resolveRetentionMs('clearTriggers')
105
+ let triggersBefore = lastTriggerTimestamp
106
+ if(shouldClear('clearTriggers')) {
107
+ triggersBefore = computeTimestampCutoff(lastTriggerTimestamp, triggersRetention)
108
+ } else {
109
+ triggersBefore = null
110
+ }
111
+
112
+ const commandsRetention = resolveRetentionMs('clearCommands')
113
+ let commandsBefore = lastCommandTimestamp
114
+ if(shouldClear('clearCommands')) {
115
+ commandsBefore = computeTimestampCutoff(lastCommandTimestamp, commandsRetention)
116
+ } else {
117
+ commandsBefore = null
118
+ }
119
+
120
+ const eventReportsRetention = resolveEventReportsRetentionMs()
121
+ let eventReportsCommandsBefore = null
122
+ let eventReportsTriggersBefore = null
123
+ if(shouldClear('clearEventReports') && eventReportsRetention !== null) {
124
+ eventReportsCommandsBefore = computeTimestampCutoff(lastCommandTimestamp, eventReportsRetention)
125
+ eventReportsTriggersBefore = computeTimestampCutoff(lastTriggerTimestamp, eventReportsRetention)
126
+ }
127
+
128
+ return {
129
+ eventsBefore,
130
+ triggersBefore,
131
+ commandsBefore,
132
+ eventReportsCommandsBefore,
133
+ eventReportsTriggersBefore,
134
+ opLogRetentionMs: shouldClear('clearOpLogs') ? resolveOpLogRetentionMs() : null
135
+ }
136
+ }
52
137
 
53
138
  function doBackup() {
54
139
  console.log("DOING BACKUP!")
@@ -62,46 +147,63 @@ function doBackup() {
62
147
  let lastTriggerTimestamp = await getLastTriggerTimestamp()
63
148
  let lastCommandTimestamp = await getLastCommandTimestamp()
64
149
 
65
- await removeOldBackups(backupsDir, 10 * TWENTY_FOUR_HOURS, 10)
66
- await createBackup(path)
150
+ await removeOldBackups(backupsDir, 10 * TWENTY_FOUR_HOURS, 10, backupLog)
151
+ await createBackup(path, backupLog)
67
152
 
68
153
  console.log("====== CLEAR STATS:")
69
154
  console.log("Last event id:", lastEventId)
70
155
  console.log("Last trigger timestamp:", lastTriggerTimestamp)
71
156
  console.log("Last command timestamp:", lastCommandTimestamp)
72
157
 
73
- if(Number.isInteger(config.clearEvents) && lastEventId) {
74
- const ts = (+(lastEventId.split(':')[0]) - config.clearEvents)
75
- console.log("Clear events before", new Date(ts))
76
- lastEventId = ts.toFixed(0).padStart(16, '0')+':'
77
- }
78
- if(Number.isInteger(config.clearTriggers) && Number.isInteger(new Date(lastTriggerTimestamp).getTime())) {
79
- const date = new Date(lastTriggerTimestamp)
80
- const old = new Date(date.getTime() - config.clearTriggers)
81
- console.log("Clear triggers before", old)
82
- lastTriggerTimestamp = old.toISOString()
158
+ const cutoffs = computeCutoffs({ lastEventId, lastTriggerTimestamp, lastCommandTimestamp })
159
+
160
+ if(cutoffs.eventsBefore) console.log("Clear events before", new Date(+(cutoffs.eventsBefore.split(':')[0])))
161
+ if(cutoffs.triggersBefore) console.log("Clear triggers before", cutoffs.triggersBefore)
162
+ if(cutoffs.commandsBefore) console.log("Clear commands before", cutoffs.commandsBefore)
163
+ if(cutoffs.eventReportsCommandsBefore) {
164
+ console.log("Clear eventReports (commands) before", cutoffs.eventReportsCommandsBefore)
83
165
  }
84
- if(Number.isInteger(config.clearCommands) && Number.isInteger(new Date(lastCommandTimestamp).getTime())) {
85
- const date = new Date(lastCommandTimestamp)
86
- const old = new Date(date.getTime() - config.clearCommands)
87
- console.log("Clear commands before", old)
88
- lastCommandTimestamp = old.toISOString()
166
+ if(cutoffs.eventReportsTriggersBefore) {
167
+ console.log("Clear eventReports (triggers) before", cutoffs.eventReportsTriggersBefore)
89
168
  }
90
169
 
91
170
  console.log("====== CLEARING:")
92
- console.log("Last remaining event id:", lastEventId)
93
- console.log("Last remaining trigger timestamp:", lastTriggerTimestamp)
94
- console.log("Last remaining command timestamp:", lastCommandTimestamp)
95
-
96
- if(config.clearCommands) await removeOldCommands(lastCommandTimestamp, { delay: 10 })
97
- if(config.clearTriggers) await removeOldTriggers(lastTriggerTimestamp, { delay: 10 })
98
- if(config.clearEvents) await removeOldEvents(lastEventId)
99
- if(config.clearOpLogs) await removeOldOpLogs()
171
+ console.log("Last remaining event id:", cutoffs.eventsBefore)
172
+ console.log("Last remaining trigger timestamp:", cutoffs.triggersBefore)
173
+ console.log("Last remaining command timestamp:", cutoffs.commandsBefore)
174
+
175
+ if(shouldClear('clearEventReports')) {
176
+ await backupLog('clearEventReports start')
177
+ await removeOldEventReports(
178
+ cutoffs.eventReportsCommandsBefore,
179
+ cutoffs.eventReportsTriggersBefore,
180
+ { delay: 10 }
181
+ )
182
+ await backupLog('clearEventReports done')
183
+ }
184
+
185
+ if(shouldClear('clearCommands') && cutoffs.commandsBefore) {
186
+ await removeOldCommands(cutoffs.commandsBefore, { delay: 10 })
187
+ }
188
+ if(shouldClear('clearTriggers') && cutoffs.triggersBefore) {
189
+ await removeOldTriggers(cutoffs.triggersBefore, { delay: 10 })
190
+ }
191
+ if(shouldClear('clearEvents') && cutoffs.eventsBefore) {
192
+ await removeOldEvents(cutoffs.eventsBefore)
193
+ }
194
+ if(shouldClear('clearOpLogs') && cutoffs.opLogRetentionMs !== null) {
195
+ await removeOldOpLogs(cutoffs.opLogRetentionMs)
196
+ }
100
197
  })
101
198
  }
102
- currentBackup.promise.then(done => {
199
+ currentBackup.promise.then(async () => {
103
200
  console.log("BACKUP CREATED!")
104
- fs.promises.writeFile(backupsDir + '/latest.txt', filename+'.tar.gz')
201
+ await fs.promises.writeFile(backupsDir + '/latest.txt', filename+'.tar.gz')
202
+ currentBackup = null
203
+ }).catch(async err => {
204
+ const detail = err && err.stack ? err.stack : String(err)
205
+ console.error("BACKUP FAILED:", err)
206
+ await backupLog(`backup failed: ${detail}`)
105
207
  currentBackup = null
106
208
  })
107
209
  return currentBackup
@@ -112,7 +214,7 @@ expressApp.get('/backup/doBackup', async (req, res) => {
112
214
  res.status(200).send('Backup in progress: ' + currentBackup.filename)
113
215
  return
114
216
  }
115
- await doBackup()
217
+ await queue.add(() => doBackup())
116
218
  res.status(200).send('Creating backup: '+currentBackup.filename)
117
219
  })
118
220
 
@@ -202,4 +304,4 @@ definition.afterStart(() => {
202
304
  }, TWENTY_FOUR_HOURS)
203
305
  })
204
306
 
205
- })
307
+ })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@live-change/backup-service",
3
- "version": "0.9.208",
3
+ "version": "0.9.212",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -9,14 +9,14 @@
9
9
  "author": "Michał Łaszczewski <michal@emikse.com>",
10
10
  "license": "BSD-3-Clause",
11
11
  "dependencies": {
12
- "@live-change/db-client": "^0.9.208",
13
- "@live-change/framework": "^0.9.208",
12
+ "@live-change/db-client": "^0.9.212",
13
+ "@live-change/framework": "^0.9.212",
14
14
  "express": "^4.18.2",
15
15
  "express-basic-auth": "^1.2.1",
16
16
  "fs-extra": "^11.1.1",
17
- "p-queue": "8.1.0",
17
+ "p-queue": "^8.1.1",
18
18
  "progress-stream": "^2.0.0"
19
19
  },
20
- "gitHead": "8818bbf0c3db428fec1f578f615e00232d3f895f",
20
+ "gitHead": "40268236c57d5f123c1e8f1815c310464d043d52",
21
21
  "type": "module"
22
22
  }