@live-change/db-client 0.9.225 → 0.9.227
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/bin/client.js +92 -4
- package/lib/dumpCommands.js +187 -0
- package/lib/exec.js +213 -37
- package/lib/filterDump.js +69 -0
- package/lib/statsDump.js +63 -0
- package/package.json +4 -4
- package/tests/dumpCommands.js +106 -0
- package/tests/statsDump.js +94 -0
package/bin/client.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import dump from '../lib/dump.js'
|
|
4
4
|
import exec from '../lib/exec.js'
|
|
5
|
+
import filterDump from '../lib/filterDump.js'
|
|
6
|
+
import statsDump from '../lib/statsDump.js'
|
|
5
7
|
import request from '../lib/request.js'
|
|
6
8
|
import get from '../lib/get.js'
|
|
7
9
|
import observe from '../lib/observe.js'
|
|
@@ -10,8 +12,8 @@ import parseList from '../lib/parseList.js'
|
|
|
10
12
|
import yargs from 'yargs'
|
|
11
13
|
|
|
12
14
|
process.on('unhandledRejection', (reason, event) => {
|
|
13
|
-
console.
|
|
14
|
-
"reason", reason, "stack", reason.stack, "promise", reason.promise)
|
|
15
|
+
console.error('Unhandled Rejection at: Promise',
|
|
16
|
+
"reason", reason, "stack", reason && reason.stack, "promise", reason && reason.promise)
|
|
15
17
|
})
|
|
16
18
|
|
|
17
19
|
process.on('uncaughtException', function (err) {
|
|
@@ -52,6 +54,36 @@ function execOptions(yargs) {
|
|
|
52
54
|
yargs.option('targetDb', {
|
|
53
55
|
describe: 'target database name'
|
|
54
56
|
})
|
|
57
|
+
yargs.option('progressFile', {
|
|
58
|
+
describe: 'progress file path (default lcdbc-exec.progress); set false to disable. line= is last successful request; after timeout use --fromLine <inFlightLine> to retry',
|
|
59
|
+
type: 'string',
|
|
60
|
+
default: 'lcdbc-exec.progress'
|
|
61
|
+
})
|
|
62
|
+
yargs.option('fromLine', {
|
|
63
|
+
describe: '1-based inclusive file line to start from (matches progress line=). After success continue with line+1; after timeout retry with inFlightLine',
|
|
64
|
+
type: 'number',
|
|
65
|
+
default: 0
|
|
66
|
+
})
|
|
67
|
+
yargs.option('skipIndex', {
|
|
68
|
+
describe: 'skip createIndex requests (restore tables/logs only)',
|
|
69
|
+
type: 'boolean',
|
|
70
|
+
default: false
|
|
71
|
+
})
|
|
72
|
+
yargs.option('onlyIndex', {
|
|
73
|
+
describe: 'run only createIndex requests (and sync barriers)',
|
|
74
|
+
type: 'boolean',
|
|
75
|
+
default: false
|
|
76
|
+
})
|
|
77
|
+
yargs.option('requestTimeout', {
|
|
78
|
+
describe: 'request timeout in ms (0 = no timeout)',
|
|
79
|
+
type: 'number',
|
|
80
|
+
default: 0
|
|
81
|
+
})
|
|
82
|
+
yargs.option('excludeTable', {
|
|
83
|
+
describe: 'exclude put/putOldLog data for table or log name (repeatable)',
|
|
84
|
+
type: 'string',
|
|
85
|
+
array: true
|
|
86
|
+
})
|
|
55
87
|
}
|
|
56
88
|
|
|
57
89
|
yargs(process.argv.slice(2)) // eslint-disable-line
|
|
@@ -125,8 +157,64 @@ yargs(process.argv.slice(2)) // eslint-disable-line
|
|
|
125
157
|
})
|
|
126
158
|
}, argv => {
|
|
127
159
|
exec({
|
|
128
|
-
serverUrl: argv.serverUrl,
|
|
129
|
-
|
|
160
|
+
serverUrl: argv.serverUrl,
|
|
161
|
+
verbose: argv.verbose,
|
|
162
|
+
file: argv.file,
|
|
163
|
+
targetDb: argv.targetDb,
|
|
164
|
+
progressFile: argv.progressFile,
|
|
165
|
+
fromLine: argv.fromLine,
|
|
166
|
+
skipIndex: argv.skipIndex,
|
|
167
|
+
onlyIndex: argv.onlyIndex,
|
|
168
|
+
requestTimeout: argv.requestTimeout,
|
|
169
|
+
excludeTable: argv.excludeTable
|
|
170
|
+
}).catch((error) => {
|
|
171
|
+
console.error(error && error.stack ? error.stack : error)
|
|
172
|
+
process.exit(1)
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
.command('filter [file]', 'filter dump jsonl to stdout (drop put/putOldLog for tables)', (yargs) => {
|
|
176
|
+
yargs.positional('file', {
|
|
177
|
+
describe: 'dump file (default stdin)',
|
|
178
|
+
default: '-'
|
|
179
|
+
})
|
|
180
|
+
yargs.option('excludeTable', {
|
|
181
|
+
describe: 'exclude put/putOldLog data for table or log name (repeatable)',
|
|
182
|
+
type: 'string',
|
|
183
|
+
array: true,
|
|
184
|
+
demandOption: true
|
|
185
|
+
})
|
|
186
|
+
}, argv => {
|
|
187
|
+
filterDump({
|
|
188
|
+
file: argv.file,
|
|
189
|
+
excludeTable: argv.excludeTable
|
|
190
|
+
}).catch((error) => {
|
|
191
|
+
console.error(error && error.stack ? error.stack : error)
|
|
192
|
+
process.exit(1)
|
|
193
|
+
})
|
|
194
|
+
})
|
|
195
|
+
.command('stats [file]', 'summarize dump put/putOldLog counts and sizes', (yargs) => {
|
|
196
|
+
yargs.positional('file', {
|
|
197
|
+
describe: 'dump file (default stdin)',
|
|
198
|
+
default: '-'
|
|
199
|
+
})
|
|
200
|
+
yargs.option('sort', {
|
|
201
|
+
describe: 'sort by bytes or entries (descending)',
|
|
202
|
+
choices: ['bytes', 'entries'],
|
|
203
|
+
default: 'bytes'
|
|
204
|
+
})
|
|
205
|
+
yargs.option('human', {
|
|
206
|
+
describe: 'human-readable sizes (KiB/MiB/GiB)',
|
|
207
|
+
type: 'boolean',
|
|
208
|
+
default: false
|
|
209
|
+
})
|
|
210
|
+
}, argv => {
|
|
211
|
+
statsDump({
|
|
212
|
+
file: argv.file,
|
|
213
|
+
sort: argv.sort,
|
|
214
|
+
human: argv.human
|
|
215
|
+
}).catch((error) => {
|
|
216
|
+
console.error(error && error.stack ? error.stack : error)
|
|
217
|
+
process.exit(1)
|
|
130
218
|
})
|
|
131
219
|
})
|
|
132
220
|
.option('verbose', {
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
function parseDumpLine(line) {
|
|
2
|
+
const trimmed = typeof line === 'string' ? line.trim() : ''
|
|
3
|
+
if(!trimmed) return { skip: true }
|
|
4
|
+
try {
|
|
5
|
+
return { command: JSON.parse(trimmed) }
|
|
6
|
+
} catch(error) {
|
|
7
|
+
return {
|
|
8
|
+
error: error.message || String(error),
|
|
9
|
+
preview: trimmed.slice(0, 120)
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function methodName(command) {
|
|
15
|
+
const method = command && command.method
|
|
16
|
+
if(!Array.isArray(method) || method.length === 0) return null
|
|
17
|
+
return method[method.length - 1]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isCreateIndex(command) {
|
|
21
|
+
return methodName(command) === 'createIndex'
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isPut(command) {
|
|
25
|
+
return methodName(command) === 'put'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isPutOldLog(command) {
|
|
29
|
+
return methodName(command) === 'putOldLog'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function putTableName(command) {
|
|
33
|
+
if(!isPut(command) && !isPutOldLog(command)) return null
|
|
34
|
+
const parameters = command && command.parameters
|
|
35
|
+
if(!Array.isArray(parameters) || parameters.length < 2) return null
|
|
36
|
+
return parameters[1]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function putObjectId(command) {
|
|
40
|
+
if(!isPut(command) && !isPutOldLog(command)) return null
|
|
41
|
+
const parameters = command && command.parameters
|
|
42
|
+
if(!Array.isArray(parameters) || parameters.length < 3) return null
|
|
43
|
+
const object = parameters[2]
|
|
44
|
+
if(!object || typeof object !== 'object') return null
|
|
45
|
+
return object.id ?? null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function shouldRunRequest(command, options = {}) {
|
|
49
|
+
const {
|
|
50
|
+
skipIndex = false,
|
|
51
|
+
onlyIndex = false,
|
|
52
|
+
excludeTables = []
|
|
53
|
+
} = options
|
|
54
|
+
const excludeSet = excludeTables instanceof Set
|
|
55
|
+
? excludeTables
|
|
56
|
+
: new Set(Array.isArray(excludeTables) ? excludeTables : [])
|
|
57
|
+
|
|
58
|
+
if(command && command.type === 'sync') return true
|
|
59
|
+
if(!command || command.type !== 'request') return false
|
|
60
|
+
|
|
61
|
+
if(onlyIndex) return isCreateIndex(command)
|
|
62
|
+
if(skipIndex && isCreateIndex(command)) return false
|
|
63
|
+
|
|
64
|
+
if(excludeSet.size > 0 && (isPut(command) || isPutOldLog(command))) {
|
|
65
|
+
const tableName = putTableName(command)
|
|
66
|
+
if(tableName != null && excludeSet.has(tableName)) return false
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return true
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function statsMapKey(kind, name) {
|
|
73
|
+
return kind + '\0' + name
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function accumulateDumpStats(statsMap, command, lineByteLength) {
|
|
77
|
+
if(!command || command.type !== 'request') return
|
|
78
|
+
let kind = null
|
|
79
|
+
if(isPut(command)) kind = 'table'
|
|
80
|
+
else if(isPutOldLog(command)) kind = 'log'
|
|
81
|
+
else return
|
|
82
|
+
|
|
83
|
+
const name = putTableName(command)
|
|
84
|
+
if(name == null) return
|
|
85
|
+
|
|
86
|
+
const bytes = Number(lineByteLength) || 0
|
|
87
|
+
const key = statsMapKey(kind, name)
|
|
88
|
+
const existing = statsMap.get(key)
|
|
89
|
+
if(existing) {
|
|
90
|
+
existing.entries += 1
|
|
91
|
+
existing.bytes += bytes
|
|
92
|
+
} else {
|
|
93
|
+
statsMap.set(key, { kind, name, entries: 1, bytes })
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function dumpStatsRows(statsMap) {
|
|
98
|
+
return Array.from(statsMap.values())
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function sortDumpStats(rows, sortBy = 'bytes') {
|
|
102
|
+
const key = sortBy === 'entries' ? 'entries' : 'bytes'
|
|
103
|
+
return [...rows].sort((a, b) => {
|
|
104
|
+
const diff = b[key] - a[key]
|
|
105
|
+
if(diff !== 0) return diff
|
|
106
|
+
if(a.kind !== b.kind) return a.kind < b.kind ? -1 : 1
|
|
107
|
+
return a.name < b.name ? -1 : (a.name > b.name ? 1 : 0)
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function formatHumanBytes(bytes) {
|
|
112
|
+
const n = Number(bytes) || 0
|
|
113
|
+
if(n < 1024) return String(n)
|
|
114
|
+
const units = ['KiB', 'MiB', 'GiB', 'TiB']
|
|
115
|
+
let value = n
|
|
116
|
+
let unitIndex = -1
|
|
117
|
+
while(value >= 1024 && unitIndex < units.length - 1) {
|
|
118
|
+
value /= 1024
|
|
119
|
+
unitIndex++
|
|
120
|
+
}
|
|
121
|
+
const rounded = value >= 100 ? value.toFixed(0) : value >= 10 ? value.toFixed(1) : value.toFixed(2)
|
|
122
|
+
return rounded + units[unitIndex]
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function formatDumpStatsTable(rows, options = {}) {
|
|
126
|
+
const { human = false } = options
|
|
127
|
+
const list = Array.isArray(rows) ? rows : []
|
|
128
|
+
let totalEntries = 0
|
|
129
|
+
let totalBytes = 0
|
|
130
|
+
for(const row of list) {
|
|
131
|
+
totalEntries += row.entries
|
|
132
|
+
totalBytes += row.bytes
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const formatBytes = (n) => human ? formatHumanBytes(n) : String(n)
|
|
136
|
+
const kindWidth = Math.max(4, ...list.map((r) => String(r.kind).length), 0)
|
|
137
|
+
const nameWidth = Math.max(4, 5, ...list.map((r) => String(r.name).length))
|
|
138
|
+
const entriesWidth = Math.max(
|
|
139
|
+
7,
|
|
140
|
+
String(totalEntries).length,
|
|
141
|
+
...list.map((r) => String(r.entries).length)
|
|
142
|
+
)
|
|
143
|
+
const bytesWidth = Math.max(
|
|
144
|
+
5,
|
|
145
|
+
formatBytes(totalBytes).length,
|
|
146
|
+
...list.map((r) => formatBytes(r.bytes).length)
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
const lines = []
|
|
150
|
+
lines.push(
|
|
151
|
+
'KIND'.padEnd(kindWidth) + ' ' +
|
|
152
|
+
'NAME'.padEnd(nameWidth) + ' ' +
|
|
153
|
+
'ENTRIES'.padStart(entriesWidth) + ' ' +
|
|
154
|
+
'BYTES'.padStart(bytesWidth)
|
|
155
|
+
)
|
|
156
|
+
for(const row of list) {
|
|
157
|
+
lines.push(
|
|
158
|
+
String(row.kind).padEnd(kindWidth) + ' ' +
|
|
159
|
+
String(row.name).padEnd(nameWidth) + ' ' +
|
|
160
|
+
String(row.entries).padStart(entriesWidth) + ' ' +
|
|
161
|
+
formatBytes(row.bytes).padStart(bytesWidth)
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
lines.push(
|
|
165
|
+
''.padEnd(kindWidth) + ' ' +
|
|
166
|
+
'TOTAL'.padEnd(nameWidth) + ' ' +
|
|
167
|
+
String(totalEntries).padStart(entriesWidth) + ' ' +
|
|
168
|
+
formatBytes(totalBytes).padStart(bytesWidth)
|
|
169
|
+
)
|
|
170
|
+
return lines.join('\n')
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export {
|
|
174
|
+
parseDumpLine,
|
|
175
|
+
methodName,
|
|
176
|
+
isCreateIndex,
|
|
177
|
+
isPut,
|
|
178
|
+
isPutOldLog,
|
|
179
|
+
putTableName,
|
|
180
|
+
putObjectId,
|
|
181
|
+
shouldRunRequest,
|
|
182
|
+
accumulateDumpStats,
|
|
183
|
+
dumpStatsRows,
|
|
184
|
+
sortDumpStats,
|
|
185
|
+
formatHumanBytes,
|
|
186
|
+
formatDumpStatsTable
|
|
187
|
+
}
|
package/lib/exec.js
CHANGED
|
@@ -1,12 +1,71 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
1
2
|
import { client as WSClient } from "@live-change/dao-websocket"
|
|
2
3
|
import lineReader from 'line-reader'
|
|
4
|
+
import {
|
|
5
|
+
parseDumpLine,
|
|
6
|
+
methodName,
|
|
7
|
+
isPut,
|
|
8
|
+
isPutOldLog,
|
|
9
|
+
putTableName,
|
|
10
|
+
putObjectId,
|
|
11
|
+
shouldRunRequest
|
|
12
|
+
} from './dumpCommands.js'
|
|
13
|
+
|
|
14
|
+
function resolveProgressFile(progressFile) {
|
|
15
|
+
if(progressFile === false || progressFile === 'false') return null
|
|
16
|
+
if(progressFile === '' || progressFile == null) return null
|
|
17
|
+
return progressFile
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function writeProgressFile(path, state) {
|
|
21
|
+
if(!path) return
|
|
22
|
+
const text = [
|
|
23
|
+
`line=${state.line ?? ''}`,
|
|
24
|
+
`inFlightLine=${state.inFlightLine ?? ''}`,
|
|
25
|
+
`inFlightMethod=${state.inFlightMethod ?? ''}`,
|
|
26
|
+
`inFlightTable=${state.inFlightTable ?? ''}`,
|
|
27
|
+
`inFlightId=${state.inFlightId ?? ''}`,
|
|
28
|
+
`updatedAt=${new Date().toISOString()}`
|
|
29
|
+
].join('\n') + '\n'
|
|
30
|
+
try {
|
|
31
|
+
fs.writeFileSync(path, text)
|
|
32
|
+
} catch(error) {
|
|
33
|
+
console.error('failed to write progress file', path, error.message || error)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
3
36
|
|
|
4
37
|
async function exec(options) {
|
|
5
|
-
let {
|
|
38
|
+
let {
|
|
39
|
+
serverUrl,
|
|
40
|
+
verbose,
|
|
41
|
+
file,
|
|
42
|
+
targetDb,
|
|
43
|
+
progressFile = 'lcdbc-exec.progress',
|
|
44
|
+
fromLine = 0,
|
|
45
|
+
skipIndex = false,
|
|
46
|
+
onlyIndex = false,
|
|
47
|
+
requestTimeout = 0,
|
|
48
|
+
excludeTable
|
|
49
|
+
} = options
|
|
6
50
|
|
|
7
|
-
|
|
51
|
+
if(skipIndex && onlyIndex) {
|
|
52
|
+
throw new Error('--skipIndex and --onlyIndex are mutually exclusive')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const excludeTables = new Set(
|
|
56
|
+
Array.isArray(excludeTable)
|
|
57
|
+
? excludeTable
|
|
58
|
+
: (excludeTable ? [excludeTable] : [])
|
|
59
|
+
)
|
|
60
|
+
const progressPath = resolveProgressFile(progressFile)
|
|
61
|
+
const timeoutMs = requestTimeout > 0 ? requestTimeout : 0
|
|
8
62
|
|
|
63
|
+
let done = false
|
|
9
64
|
let sourceDb
|
|
65
|
+
const startedTables = new Set()
|
|
66
|
+
let lastCompletedLine = 0
|
|
67
|
+
let inFlight = null
|
|
68
|
+
let progressTimer = null
|
|
10
69
|
|
|
11
70
|
const clientPromise = new Promise((resolve, reject) => {
|
|
12
71
|
const client = new WSClient("commandLine", serverUrl, {
|
|
@@ -19,6 +78,16 @@ async function exec(options) {
|
|
|
19
78
|
},
|
|
20
79
|
onDisconnect: () => {
|
|
21
80
|
if(verbose) console.error("disconnected from server")
|
|
81
|
+
if(progressTimer) clearInterval(progressTimer)
|
|
82
|
+
if(progressPath) {
|
|
83
|
+
writeProgressFile(progressPath, {
|
|
84
|
+
line: lastCompletedLine,
|
|
85
|
+
inFlightLine: inFlight?.line,
|
|
86
|
+
inFlightMethod: inFlight?.method,
|
|
87
|
+
inFlightTable: inFlight?.table,
|
|
88
|
+
inFlightId: inFlight?.id
|
|
89
|
+
})
|
|
90
|
+
}
|
|
22
91
|
if(!done) {
|
|
23
92
|
console.error("disconnected before request done")
|
|
24
93
|
process.exit(1)
|
|
@@ -35,6 +104,19 @@ async function exec(options) {
|
|
|
35
104
|
const client = await clientPromise
|
|
36
105
|
let currentPromises = []
|
|
37
106
|
const maxPromises = 1
|
|
107
|
+
let lineNo = 0
|
|
108
|
+
|
|
109
|
+
if(progressPath) {
|
|
110
|
+
progressTimer = setInterval(() => {
|
|
111
|
+
writeProgressFile(progressPath, {
|
|
112
|
+
line: lastCompletedLine,
|
|
113
|
+
inFlightLine: inFlight?.line,
|
|
114
|
+
inFlightMethod: inFlight?.method,
|
|
115
|
+
inFlightTable: inFlight?.table,
|
|
116
|
+
inFlightId: inFlight?.id
|
|
117
|
+
})
|
|
118
|
+
}, 1000)
|
|
119
|
+
}
|
|
38
120
|
|
|
39
121
|
function nextLine() {
|
|
40
122
|
return new Promise(function(resolve, reject) {
|
|
@@ -44,47 +126,141 @@ async function exec(options) {
|
|
|
44
126
|
})
|
|
45
127
|
})
|
|
46
128
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
if(command.type == 'request') {
|
|
52
|
-
console.log(" ", command.method.join("."), command.parameters.slice(0,2).map(p=>JSON.stringify(p)).join(", "))
|
|
53
|
-
} */
|
|
54
|
-
switch(command.type) {
|
|
55
|
-
case 'request' :
|
|
56
|
-
while(currentPromises.length > maxPromises) {
|
|
57
|
-
await currentPromises[0]
|
|
58
|
-
currentPromises.shift()
|
|
59
|
-
}
|
|
60
|
-
if(!sourceDb) {
|
|
61
|
-
sourceDb = command.parameters[0]
|
|
62
|
-
}
|
|
63
|
-
if(targetDb) {
|
|
64
|
-
if(sourceDb != command.parameters[0])
|
|
65
|
-
throw new Error(`source database changed from ${sourceDb} to ${command.parameters[0]}`)
|
|
66
|
-
command.parameters[0] = targetDb
|
|
67
|
-
}
|
|
68
|
-
//console.log("REQUEST", command.method, command.parameters)
|
|
69
|
-
currentPromises.push(client.request(command.method, ...command.parameters))
|
|
70
|
-
break;
|
|
71
|
-
case 'sync' :
|
|
72
|
-
await Promise.all(currentPromises)
|
|
73
|
-
currentPromises = []
|
|
74
|
-
break;
|
|
129
|
+
|
|
130
|
+
function sendRequest(method, parameters) {
|
|
131
|
+
if(timeoutMs) {
|
|
132
|
+
return client.requestWithSettings({ requestTimeout: timeoutMs }, method, ...parameters)
|
|
75
133
|
}
|
|
134
|
+
return client.request(method, ...parameters)
|
|
76
135
|
}
|
|
77
|
-
reader.close(function(err) {
|
|
78
|
-
if (err) throw err
|
|
79
|
-
})
|
|
80
136
|
|
|
81
|
-
|
|
137
|
+
async function flushPending() {
|
|
138
|
+
while(currentPromises.length > 0) {
|
|
139
|
+
await currentPromises[0]
|
|
140
|
+
currentPromises.shift()
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
while (reader.hasNextLine()) {
|
|
146
|
+
const line = await nextLine()
|
|
147
|
+
lineNo++
|
|
148
|
+
|
|
149
|
+
if(fromLine > 0 && lineNo < fromLine) continue
|
|
150
|
+
|
|
151
|
+
const parsed = parseDumpLine(line)
|
|
152
|
+
if(parsed.skip) {
|
|
153
|
+
lastCompletedLine = lineNo
|
|
154
|
+
continue
|
|
155
|
+
}
|
|
156
|
+
if(parsed.error) {
|
|
157
|
+
console.error(`skip bad json line ${lineNo}: ${parsed.preview}`)
|
|
158
|
+
lastCompletedLine = lineNo
|
|
159
|
+
continue
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const command = parsed.command
|
|
163
|
+
if(command.type === 'sync') {
|
|
164
|
+
await flushPending()
|
|
165
|
+
lastCompletedLine = lineNo
|
|
166
|
+
continue
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if(command.type !== 'request') {
|
|
170
|
+
lastCompletedLine = lineNo
|
|
171
|
+
continue
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if(!shouldRunRequest(command, { skipIndex, onlyIndex, excludeTables })) {
|
|
175
|
+
lastCompletedLine = lineNo
|
|
176
|
+
continue
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
while(currentPromises.length > maxPromises) {
|
|
180
|
+
await currentPromises[0]
|
|
181
|
+
currentPromises.shift()
|
|
182
|
+
}
|
|
82
183
|
|
|
83
|
-
|
|
184
|
+
if(!sourceDb) {
|
|
185
|
+
sourceDb = command.parameters[0]
|
|
186
|
+
}
|
|
187
|
+
if(targetDb) {
|
|
188
|
+
if(sourceDb != command.parameters[0]) {
|
|
189
|
+
throw new Error(`source database changed from ${sourceDb} to ${command.parameters[0]}`)
|
|
190
|
+
}
|
|
191
|
+
command.parameters[0] = targetDb
|
|
192
|
+
}
|
|
84
193
|
|
|
85
|
-
|
|
194
|
+
const method = methodName(command)
|
|
195
|
+
const table = putTableName(command)
|
|
196
|
+
const objectId = putObjectId(command)
|
|
86
197
|
|
|
198
|
+
if((isPut(command) || isPutOldLog(command)) && table && !startedTables.has(table)) {
|
|
199
|
+
startedTables.add(table)
|
|
200
|
+
console.error(`table put start: ${table} (line ${lineNo})`)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
inFlight = {
|
|
204
|
+
line: lineNo,
|
|
205
|
+
method,
|
|
206
|
+
table: table || '',
|
|
207
|
+
id: objectId || ''
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const requestPromise = sendRequest(command.method, command.parameters)
|
|
211
|
+
.then(() => {
|
|
212
|
+
lastCompletedLine = lineNo
|
|
213
|
+
if(inFlight && inFlight.line === lineNo) inFlight = null
|
|
214
|
+
})
|
|
215
|
+
.catch((error) => {
|
|
216
|
+
if(error === 'timeout' || error?.message === 'timeout') {
|
|
217
|
+
console.error(
|
|
218
|
+
`request timeout after ${timeoutMs}ms: ${method}`
|
|
219
|
+
+ (table ? ` table=${table}` : '')
|
|
220
|
+
+ (objectId != null ? ` id=${objectId}` : '')
|
|
221
|
+
+ ` line=${lineNo}`
|
|
222
|
+
)
|
|
223
|
+
if(progressPath) {
|
|
224
|
+
writeProgressFile(progressPath, {
|
|
225
|
+
line: lastCompletedLine,
|
|
226
|
+
inFlightLine: lineNo,
|
|
227
|
+
inFlightMethod: method,
|
|
228
|
+
inFlightTable: table || '',
|
|
229
|
+
inFlightId: objectId || ''
|
|
230
|
+
})
|
|
231
|
+
}
|
|
232
|
+
if(progressTimer) clearInterval(progressTimer)
|
|
233
|
+
process.exit(1)
|
|
234
|
+
}
|
|
235
|
+
throw error
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
currentPromises.push(requestPromise)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
await flushPending()
|
|
242
|
+
reader.close(function(closeErr) {
|
|
243
|
+
if (closeErr) throw closeErr
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
done = true
|
|
247
|
+
if(progressTimer) clearInterval(progressTimer)
|
|
248
|
+
if(progressPath) {
|
|
249
|
+
writeProgressFile(progressPath, {
|
|
250
|
+
line: lastCompletedLine,
|
|
251
|
+
inFlightLine: '',
|
|
252
|
+
inFlightMethod: '',
|
|
253
|
+
inFlightTable: '',
|
|
254
|
+
inFlightId: ''
|
|
255
|
+
})
|
|
256
|
+
}
|
|
257
|
+
client.dispose()
|
|
258
|
+
} catch(error) {
|
|
259
|
+
if(progressTimer) clearInterval(progressTimer)
|
|
260
|
+
console.error(error && error.stack ? error.stack : error)
|
|
261
|
+
process.exit(1)
|
|
262
|
+
}
|
|
87
263
|
})
|
|
88
264
|
}
|
|
89
265
|
|
|
90
|
-
export default exec
|
|
266
|
+
export default exec
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import lineReader from 'line-reader'
|
|
2
|
+
import {
|
|
3
|
+
parseDumpLine,
|
|
4
|
+
shouldRunRequest
|
|
5
|
+
} from './dumpCommands.js'
|
|
6
|
+
|
|
7
|
+
async function filterDump(options) {
|
|
8
|
+
const {
|
|
9
|
+
file = '-',
|
|
10
|
+
excludeTable
|
|
11
|
+
} = options
|
|
12
|
+
|
|
13
|
+
const excludeTables = Array.isArray(excludeTable)
|
|
14
|
+
? excludeTable
|
|
15
|
+
: (excludeTable ? [excludeTable] : [])
|
|
16
|
+
|
|
17
|
+
if(excludeTables.length === 0) {
|
|
18
|
+
throw new Error('--excludeTable is required at least once')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const excludeSet = new Set(excludeTables)
|
|
22
|
+
let lineNo = 0
|
|
23
|
+
|
|
24
|
+
await new Promise((resolve, reject) => {
|
|
25
|
+
lineReader.open(file == '-' ? process.stdin : file, function(err, reader) {
|
|
26
|
+
if(err) return reject(err)
|
|
27
|
+
|
|
28
|
+
function nextLine() {
|
|
29
|
+
return new Promise(function(res, rej) {
|
|
30
|
+
reader.nextLine(function(readErr, line) {
|
|
31
|
+
if(readErr) return rej(readErr)
|
|
32
|
+
res(line)
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
;(async () => {
|
|
38
|
+
try {
|
|
39
|
+
while(reader.hasNextLine()) {
|
|
40
|
+
const line = await nextLine()
|
|
41
|
+
lineNo++
|
|
42
|
+
const parsed = parseDumpLine(line)
|
|
43
|
+
if(parsed.skip) continue
|
|
44
|
+
if(parsed.error) {
|
|
45
|
+
console.error(`skip bad json line ${lineNo}: ${parsed.preview}`)
|
|
46
|
+
continue
|
|
47
|
+
}
|
|
48
|
+
const command = parsed.command
|
|
49
|
+
if(command.type === 'sync') {
|
|
50
|
+
process.stdout.write(line.endsWith('\n') ? line : line + '\n')
|
|
51
|
+
continue
|
|
52
|
+
}
|
|
53
|
+
if(command.type !== 'request') continue
|
|
54
|
+
if(!shouldRunRequest(command, { excludeTables: excludeSet })) continue
|
|
55
|
+
process.stdout.write(line.endsWith('\n') ? line : line + '\n')
|
|
56
|
+
}
|
|
57
|
+
reader.close(function(closeErr) {
|
|
58
|
+
if(closeErr) return reject(closeErr)
|
|
59
|
+
resolve()
|
|
60
|
+
})
|
|
61
|
+
} catch(error) {
|
|
62
|
+
reject(error)
|
|
63
|
+
}
|
|
64
|
+
})()
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export default filterDump
|
package/lib/statsDump.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import lineReader from 'line-reader'
|
|
2
|
+
import {
|
|
3
|
+
parseDumpLine,
|
|
4
|
+
accumulateDumpStats,
|
|
5
|
+
dumpStatsRows,
|
|
6
|
+
sortDumpStats,
|
|
7
|
+
formatDumpStatsTable
|
|
8
|
+
} from './dumpCommands.js'
|
|
9
|
+
|
|
10
|
+
async function statsDump(options) {
|
|
11
|
+
const {
|
|
12
|
+
file = '-',
|
|
13
|
+
sort = 'bytes',
|
|
14
|
+
human = false
|
|
15
|
+
} = options
|
|
16
|
+
|
|
17
|
+
const statsMap = new Map()
|
|
18
|
+
let lineNo = 0
|
|
19
|
+
|
|
20
|
+
await new Promise((resolve, reject) => {
|
|
21
|
+
lineReader.open(file == '-' ? process.stdin : file, function(err, reader) {
|
|
22
|
+
if(err) return reject(err)
|
|
23
|
+
|
|
24
|
+
function nextLine() {
|
|
25
|
+
return new Promise(function(res, rej) {
|
|
26
|
+
reader.nextLine(function(readErr, line) {
|
|
27
|
+
if(readErr) return rej(readErr)
|
|
28
|
+
res(line)
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
;(async () => {
|
|
34
|
+
try {
|
|
35
|
+
while(reader.hasNextLine()) {
|
|
36
|
+
const line = await nextLine()
|
|
37
|
+
lineNo++
|
|
38
|
+
const parsed = parseDumpLine(line)
|
|
39
|
+
if(parsed.skip) continue
|
|
40
|
+
if(parsed.error) {
|
|
41
|
+
console.error(`skip bad json line ${lineNo}: ${parsed.preview}`)
|
|
42
|
+
continue
|
|
43
|
+
}
|
|
44
|
+
const command = parsed.command
|
|
45
|
+
const lineBytes = Buffer.byteLength(line, 'utf8')
|
|
46
|
+
accumulateDumpStats(statsMap, command, lineBytes)
|
|
47
|
+
}
|
|
48
|
+
reader.close(function(closeErr) {
|
|
49
|
+
if(closeErr) return reject(closeErr)
|
|
50
|
+
resolve()
|
|
51
|
+
})
|
|
52
|
+
} catch(error) {
|
|
53
|
+
reject(error)
|
|
54
|
+
}
|
|
55
|
+
})()
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const rows = sortDumpStats(dumpStatsRows(statsMap), sort)
|
|
60
|
+
console.log(formatDumpStatsTable(rows, { human }))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export default statsDump
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@live-change/db-client",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.227",
|
|
4
4
|
"description": "Database with observable data for live queries",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -28,11 +28,11 @@
|
|
|
28
28
|
},
|
|
29
29
|
"type": "module",
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@live-change/dao": "^0.9.
|
|
32
|
-
"@live-change/dao-websocket": "^0.9.
|
|
31
|
+
"@live-change/dao": "^0.9.227",
|
|
32
|
+
"@live-change/dao-websocket": "^0.9.227",
|
|
33
33
|
"line-reader": "^0.4.0",
|
|
34
34
|
"websocket": "^1.0.34",
|
|
35
35
|
"yargs": "^17.7.2"
|
|
36
36
|
},
|
|
37
|
-
"gitHead": "
|
|
37
|
+
"gitHead": "7d4dc92dcb9a50381292dded7d8766a3cadbecf9"
|
|
38
38
|
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import test from 'tape'
|
|
2
|
+
import {
|
|
3
|
+
parseDumpLine,
|
|
4
|
+
methodName,
|
|
5
|
+
isCreateIndex,
|
|
6
|
+
isPut,
|
|
7
|
+
isPutOldLog,
|
|
8
|
+
putTableName,
|
|
9
|
+
putObjectId,
|
|
10
|
+
shouldRunRequest
|
|
11
|
+
} from '../lib/dumpCommands.js'
|
|
12
|
+
|
|
13
|
+
test('parseDumpLine skips empty', (t) => {
|
|
14
|
+
t.deepEqual(parseDumpLine(''), { skip: true })
|
|
15
|
+
t.deepEqual(parseDumpLine(' '), { skip: true })
|
|
16
|
+
t.end()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test('parseDumpLine parses request', (t) => {
|
|
20
|
+
const line = JSON.stringify({
|
|
21
|
+
type: 'request',
|
|
22
|
+
method: ['database', 'put'],
|
|
23
|
+
parameters: ['db', 'tableA', { id: 'x1' }]
|
|
24
|
+
})
|
|
25
|
+
const parsed = parseDumpLine(line)
|
|
26
|
+
t.equal(parsed.command.type, 'request')
|
|
27
|
+
t.equal(methodName(parsed.command), 'put')
|
|
28
|
+
t.end()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('parseDumpLine returns error for invalid json', (t) => {
|
|
32
|
+
const parsed = parseDumpLine('Unhandled Rejection at: Promise')
|
|
33
|
+
t.ok(parsed.error)
|
|
34
|
+
t.ok(parsed.preview.startsWith('Unhandled'))
|
|
35
|
+
t.end()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('put helpers extract table and id', (t) => {
|
|
39
|
+
const command = {
|
|
40
|
+
type: 'request',
|
|
41
|
+
method: ['database', 'put'],
|
|
42
|
+
parameters: ['db', 'pageSnapshot', { id: 'abc' }]
|
|
43
|
+
}
|
|
44
|
+
t.ok(isPut(command))
|
|
45
|
+
t.notOk(isPutOldLog(command))
|
|
46
|
+
t.equal(putTableName(command), 'pageSnapshot')
|
|
47
|
+
t.equal(putObjectId(command), 'abc')
|
|
48
|
+
t.end()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('shouldRunRequest skipIndex drops createIndex', (t) => {
|
|
52
|
+
const createIndex = {
|
|
53
|
+
type: 'request',
|
|
54
|
+
method: ['database', 'createIndex'],
|
|
55
|
+
parameters: ['db', 'byName', '()=>{}', {}]
|
|
56
|
+
}
|
|
57
|
+
const put = {
|
|
58
|
+
type: 'request',
|
|
59
|
+
method: ['database', 'put'],
|
|
60
|
+
parameters: ['db', 't', { id: '1' }]
|
|
61
|
+
}
|
|
62
|
+
t.ok(isCreateIndex(createIndex))
|
|
63
|
+
t.notOk(shouldRunRequest(createIndex, { skipIndex: true }))
|
|
64
|
+
t.ok(shouldRunRequest(put, { skipIndex: true }))
|
|
65
|
+
t.ok(shouldRunRequest({ type: 'sync' }, { skipIndex: true }))
|
|
66
|
+
t.end()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('shouldRunRequest onlyIndex keeps createIndex and sync', (t) => {
|
|
70
|
+
const createIndex = {
|
|
71
|
+
type: 'request',
|
|
72
|
+
method: ['database', 'createIndex'],
|
|
73
|
+
parameters: ['db', 'byName', '()=>{}', {}]
|
|
74
|
+
}
|
|
75
|
+
const put = {
|
|
76
|
+
type: 'request',
|
|
77
|
+
method: ['database', 'put'],
|
|
78
|
+
parameters: ['db', 't', { id: '1' }]
|
|
79
|
+
}
|
|
80
|
+
t.ok(shouldRunRequest(createIndex, { onlyIndex: true }))
|
|
81
|
+
t.notOk(shouldRunRequest(put, { onlyIndex: true }))
|
|
82
|
+
t.ok(shouldRunRequest({ type: 'sync' }, { onlyIndex: true }))
|
|
83
|
+
t.end()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('shouldRunRequest excludeTables drops put and putOldLog data', (t) => {
|
|
87
|
+
const put = {
|
|
88
|
+
type: 'request',
|
|
89
|
+
method: ['database', 'put'],
|
|
90
|
+
parameters: ['db', 'Foo', { id: '1' }]
|
|
91
|
+
}
|
|
92
|
+
const putOldLog = {
|
|
93
|
+
type: 'request',
|
|
94
|
+
method: ['database', 'putOldLog'],
|
|
95
|
+
parameters: ['db', 'Bar', { id: '2' }]
|
|
96
|
+
}
|
|
97
|
+
const createTable = {
|
|
98
|
+
type: 'request',
|
|
99
|
+
method: ['database', 'createTable'],
|
|
100
|
+
parameters: ['db', 'Foo']
|
|
101
|
+
}
|
|
102
|
+
t.notOk(shouldRunRequest(put, { excludeTables: ['Foo'] }))
|
|
103
|
+
t.notOk(shouldRunRequest(putOldLog, { excludeTables: ['Bar'] }))
|
|
104
|
+
t.ok(shouldRunRequest(createTable, { excludeTables: ['Foo'] }))
|
|
105
|
+
t.end()
|
|
106
|
+
})
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import test from 'tape'
|
|
2
|
+
import {
|
|
3
|
+
accumulateDumpStats,
|
|
4
|
+
dumpStatsRows,
|
|
5
|
+
sortDumpStats,
|
|
6
|
+
formatHumanBytes,
|
|
7
|
+
formatDumpStatsTable
|
|
8
|
+
} from '../lib/dumpCommands.js'
|
|
9
|
+
|
|
10
|
+
test('accumulateDumpStats sums put entries and bytes', (t) => {
|
|
11
|
+
const map = new Map()
|
|
12
|
+
const put = {
|
|
13
|
+
type: 'request',
|
|
14
|
+
method: ['database', 'put'],
|
|
15
|
+
parameters: ['db', 'Foo', { id: '1' }]
|
|
16
|
+
}
|
|
17
|
+
accumulateDumpStats(map, put, 100)
|
|
18
|
+
accumulateDumpStats(map, put, 50)
|
|
19
|
+
const rows = dumpStatsRows(map)
|
|
20
|
+
t.equal(rows.length, 1)
|
|
21
|
+
t.deepEqual(rows[0], { kind: 'table', name: 'Foo', entries: 2, bytes: 150 })
|
|
22
|
+
t.end()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('accumulateDumpStats separates table and log with same name', (t) => {
|
|
26
|
+
const map = new Map()
|
|
27
|
+
accumulateDumpStats(map, {
|
|
28
|
+
type: 'request',
|
|
29
|
+
method: ['database', 'put'],
|
|
30
|
+
parameters: ['db', 'Same', { id: '1' }]
|
|
31
|
+
}, 10)
|
|
32
|
+
accumulateDumpStats(map, {
|
|
33
|
+
type: 'request',
|
|
34
|
+
method: ['database', 'putOldLog'],
|
|
35
|
+
parameters: ['db', 'Same', { id: '2' }]
|
|
36
|
+
}, 20)
|
|
37
|
+
const rows = sortDumpStats(dumpStatsRows(map), 'bytes')
|
|
38
|
+
t.equal(rows.length, 2)
|
|
39
|
+
t.equal(rows[0].kind, 'log')
|
|
40
|
+
t.equal(rows[0].bytes, 20)
|
|
41
|
+
t.equal(rows[1].kind, 'table')
|
|
42
|
+
t.equal(rows[1].bytes, 10)
|
|
43
|
+
t.end()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('accumulateDumpStats ignores createTable sync and non-request', (t) => {
|
|
47
|
+
const map = new Map()
|
|
48
|
+
accumulateDumpStats(map, {
|
|
49
|
+
type: 'request',
|
|
50
|
+
method: ['database', 'createTable'],
|
|
51
|
+
parameters: ['db', 'Foo']
|
|
52
|
+
}, 999)
|
|
53
|
+
accumulateDumpStats(map, { type: 'sync' }, 50)
|
|
54
|
+
accumulateDumpStats(map, null, 10)
|
|
55
|
+
t.equal(dumpStatsRows(map).length, 0)
|
|
56
|
+
t.end()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('sortDumpStats by entries descending', (t) => {
|
|
60
|
+
const rows = [
|
|
61
|
+
{ kind: 'table', name: 'A', entries: 1, bytes: 1000 },
|
|
62
|
+
{ kind: 'table', name: 'B', entries: 5, bytes: 10 },
|
|
63
|
+
{ kind: 'log', name: 'C', entries: 3, bytes: 100 }
|
|
64
|
+
]
|
|
65
|
+
const byEntries = sortDumpStats(rows, 'entries')
|
|
66
|
+
t.deepEqual(byEntries.map((r) => r.name), ['B', 'C', 'A'])
|
|
67
|
+
const byBytes = sortDumpStats(rows, 'bytes')
|
|
68
|
+
t.deepEqual(byBytes.map((r) => r.name), ['A', 'C', 'B'])
|
|
69
|
+
t.end()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('formatHumanBytes', (t) => {
|
|
73
|
+
t.equal(formatHumanBytes(500), '500')
|
|
74
|
+
t.equal(formatHumanBytes(2048), '2.00KiB')
|
|
75
|
+
t.equal(formatHumanBytes(1024 * 1024), '1.00MiB')
|
|
76
|
+
t.end()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('formatDumpStatsTable includes TOTAL and human sizes', (t) => {
|
|
80
|
+
const rows = [
|
|
81
|
+
{ kind: 'log', name: 'BigLog', entries: 2, bytes: 2048 },
|
|
82
|
+
{ kind: 'table', name: 'Small', entries: 1, bytes: 10 }
|
|
83
|
+
]
|
|
84
|
+
const plain = formatDumpStatsTable(rows, { human: false })
|
|
85
|
+
t.ok(plain.includes('TOTAL'))
|
|
86
|
+
t.ok(plain.includes('BigLog'))
|
|
87
|
+
t.ok(plain.includes('2058'))
|
|
88
|
+
t.ok(plain.includes('3'))
|
|
89
|
+
|
|
90
|
+
const human = formatDumpStatsTable(rows, { human: true })
|
|
91
|
+
t.ok(human.includes('2.00KiB'))
|
|
92
|
+
t.ok(human.includes('TOTAL'))
|
|
93
|
+
t.end()
|
|
94
|
+
})
|