buddy-workbench 0.1.25 → 0.1.27

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": "buddy-workbench",
3
- "version": "0.1.25",
3
+ "version": "0.1.27",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,6 +23,25 @@
23
23
  "prepublishOnly": "npm run build",
24
24
  "version": "node -e \"const v=process.env.npm_package_version; const fs=require('fs'); ['ui/package.json', 'ui/package-lock.json'].forEach(p=>{if(fs.existsSync(p)){const j=JSON.parse(fs.readFileSync(p)); j.version=v; if(j.packages&&j.packages['']){j.packages[''].version=v;} fs.writeFileSync(p, JSON.stringify(j,null,2)+'\\n');}});\" && git add ui/package.json ui/package-lock.json"
25
25
  },
26
+ "devbuddyChangelog": [
27
+ {
28
+ "version": "0.1.27",
29
+ "name": "Data backups and settings refinements",
30
+ "notes": [
31
+ "Added data directory size statistics and secure .tar.gz backup import and export.",
32
+ "Refined the Static Pages action column header alignment."
33
+ ]
34
+ },
35
+ {
36
+ "version": "0.1.26",
37
+ "name": "Light theme and update center",
38
+ "notes": [
39
+ "Added a switchable light theme with persistent appearance settings.",
40
+ "Refined the light theme window color, primary actions, and navigation styling.",
41
+ "Added an npm-based update indicator and in-app Change Log."
42
+ ]
43
+ }
44
+ ],
26
45
  "dependencies": {
27
46
  "@jsquash/jpeg": "1.6.0",
28
47
  "axios": "1.7.9",
@@ -14,6 +14,7 @@ export function settingsStatus() {
14
14
  jiraIssuePrefix: typeof settings.jiraIssuePrefix === 'string' ? settings.jiraIssuePrefix : '',
15
15
  defaultEditor: typeof settings.defaultEditor === 'string' ? settings.defaultEditor : 'vscode',
16
16
  defaultBrowser: typeof settings.defaultBrowser === 'string' ? settings.defaultBrowser : 'chrome',
17
+ theme: settings.theme === 'light' ? 'light' : 'dark',
17
18
  bitbucketTokenConfigured: Boolean(settings.bitbucketAccessToken),
18
19
  jiraTokenConfigured: Boolean(settings.jiraAccessToken),
19
20
  confluenceTokenConfigured: Boolean(settings.confluenceAccessToken),
@@ -61,6 +62,14 @@ export function saveDefaultBrowser(defaultBrowser) {
61
62
  return settingsStatus();
62
63
  }
63
64
 
65
+ export function saveTheme(theme) {
66
+ const settings = readSettings();
67
+ settings.theme = theme === 'light' ? 'light' : 'dark';
68
+ mkdirSync(dirname(paths.settings), { recursive: true });
69
+ writeFileSync(paths.settings, JSON.stringify(settings, null, 2), { mode: 0o600 });
70
+ return settingsStatus();
71
+ }
72
+
64
73
  export function saveClipboardEnabled(enabled) {
65
74
  const settings = readSettings();
66
75
  settings.clipboardEnabled = Boolean(enabled);
@@ -0,0 +1,285 @@
1
+ import { createReadStream, createWriteStream, existsSync } from 'node:fs';
2
+ import { copyFile, lstat, mkdir, mkdtemp, open, readdir, rm, stat, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { dirname, join, posix, resolve, sep } from 'node:path';
5
+ import { Transform } from 'node:stream';
6
+ import { pipeline } from 'node:stream/promises';
7
+ import { createGunzip, createGzip } from 'node:zlib';
8
+ import { once } from 'node:events';
9
+ import { Router } from 'express';
10
+ import { paths } from '../config.js';
11
+
12
+ const router = Router();
13
+ const TAR_BLOCK_SIZE = 512;
14
+ const MAX_COMPRESSED_BYTES = 512 * 1024 * 1024;
15
+ const MAX_EXPANDED_BYTES = 2 * 1024 * 1024 * 1024;
16
+ const MAX_IMPORTED_FILES = 100000;
17
+
18
+ async function collectFiles(directory, relativeDirectory = '') {
19
+ if (!existsSync(directory)) return [];
20
+ const entries = await readdir(directory, { withFileTypes: true });
21
+ const files = [];
22
+
23
+ for (const entry of entries) {
24
+ const absolutePath = join(directory, entry.name);
25
+ const relativePath = relativeDirectory ? `${relativeDirectory}/${entry.name}` : entry.name;
26
+ if (entry.isDirectory()) {
27
+ files.push(...await collectFiles(absolutePath, relativePath));
28
+ } else if (entry.isFile()) {
29
+ const fileStat = await lstat(absolutePath);
30
+ files.push({
31
+ absolutePath,
32
+ relativePath: relativePath.replaceAll('\\', '/'),
33
+ size: fileStat.size,
34
+ mtime: fileStat.mtime
35
+ });
36
+ }
37
+ }
38
+
39
+ return files;
40
+ }
41
+
42
+ async function dataDirectoryStats() {
43
+ const files = await collectFiles(paths.dataDir);
44
+ return {
45
+ sizeBytes: files.reduce((total, file) => total + file.size, 0),
46
+ fileCount: files.length
47
+ };
48
+ }
49
+
50
+ function splitTarPath(relativePath) {
51
+ if (Buffer.byteLength(relativePath) <= 100) return { name: relativePath, prefix: '' };
52
+ const separators = [...relativePath.matchAll(/\//g)].map((match) => match.index).reverse();
53
+ for (const index of separators) {
54
+ const prefix = relativePath.slice(0, index);
55
+ const name = relativePath.slice(index + 1);
56
+ if (Buffer.byteLength(prefix) <= 155 && Buffer.byteLength(name) <= 100) {
57
+ return { name, prefix };
58
+ }
59
+ }
60
+ throw new Error(`Backup path is too long: ${relativePath}`);
61
+ }
62
+
63
+ function writeString(buffer, value, offset, length) {
64
+ const encoded = Buffer.from(String(value));
65
+ if (encoded.length > length) throw new Error(`Tar field exceeds ${length} bytes.`);
66
+ encoded.copy(buffer, offset);
67
+ }
68
+
69
+ function writeOctal(buffer, value, offset, length) {
70
+ writeString(buffer, Math.max(0, value).toString(8).padStart(length - 1, '0'), offset, length - 1);
71
+ }
72
+
73
+ function createTarHeader(file) {
74
+ const header = Buffer.alloc(TAR_BLOCK_SIZE);
75
+ const { name, prefix } = splitTarPath(file.relativePath);
76
+ writeString(header, name, 0, 100);
77
+ writeOctal(header, 0o600, 100, 8);
78
+ writeOctal(header, 0, 108, 8);
79
+ writeOctal(header, 0, 116, 8);
80
+ writeOctal(header, file.size, 124, 12);
81
+ writeOctal(header, Math.floor(file.mtime.getTime() / 1000), 136, 12);
82
+ header.fill(0x20, 148, 156);
83
+ writeString(header, '0', 156, 1);
84
+ writeString(header, 'ustar', 257, 6);
85
+ writeString(header, '00', 263, 2);
86
+ writeString(header, 'devbuddy', 265, 32);
87
+ writeString(header, 'devbuddy', 297, 32);
88
+ if (prefix) writeString(header, prefix, 345, 155);
89
+
90
+ const checksum = header.reduce((total, byte) => total + byte, 0);
91
+ writeString(header, checksum.toString(8).padStart(6, '0'), 148, 6);
92
+ header[154] = 0;
93
+ header[155] = 0x20;
94
+ return header;
95
+ }
96
+
97
+ async function writeWithBackpressure(stream, chunk) {
98
+ if (!stream.write(chunk)) await once(stream, 'drain');
99
+ }
100
+
101
+ function nullTerminatedString(buffer, offset, length) {
102
+ const field = buffer.subarray(offset, offset + length);
103
+ const nullIndex = field.indexOf(0);
104
+ return field.subarray(0, nullIndex === -1 ? field.length : nullIndex).toString('utf8').trim();
105
+ }
106
+
107
+ function parseOctal(buffer, offset, length) {
108
+ const value = nullTerminatedString(buffer, offset, length).trim();
109
+ if (!value) return 0;
110
+ const parsed = Number.parseInt(value, 8);
111
+ if (!Number.isFinite(parsed) || parsed < 0) throw new Error('Backup contains an invalid tar size.');
112
+ return parsed;
113
+ }
114
+
115
+ function verifyTarHeader(header) {
116
+ const expected = parseOctal(header, 148, 8);
117
+ const copy = Buffer.from(header);
118
+ copy.fill(0x20, 148, 156);
119
+ const actual = copy.reduce((total, byte) => total + byte, 0);
120
+ if (expected !== actual) throw new Error('Backup archive checksum is invalid.');
121
+ }
122
+
123
+ function safeArchiveTarget(stagingDirectory, archivePath) {
124
+ const normalizedInput = archivePath.replaceAll('\\', '/');
125
+ const normalized = posix.normalize(normalizedInput);
126
+ if (
127
+ !normalized ||
128
+ normalized === '.' ||
129
+ normalized === '..' ||
130
+ normalized.startsWith('../') ||
131
+ normalized.startsWith('/') ||
132
+ /^[a-zA-Z]:/.test(normalized)
133
+ ) {
134
+ throw new Error(`Backup contains an unsafe path: ${archivePath}`);
135
+ }
136
+ const target = resolve(stagingDirectory, ...normalized.split('/'));
137
+ const stagingRoot = resolve(stagingDirectory);
138
+ if (target !== stagingRoot && !target.startsWith(`${stagingRoot}${sep}`)) {
139
+ throw new Error(`Backup path escapes the data directory: ${archivePath}`);
140
+ }
141
+ return target;
142
+ }
143
+
144
+ function byteLimit(limit, label) {
145
+ let total = 0;
146
+ return new Transform({
147
+ transform(chunk, _encoding, callback) {
148
+ total += chunk.length;
149
+ if (total > limit) {
150
+ callback(new Error(`${label} exceeds the supported size limit.`));
151
+ } else {
152
+ callback(null, chunk);
153
+ }
154
+ }
155
+ });
156
+ }
157
+
158
+ async function extractTar(tarPath, stagingDirectory) {
159
+ const archiveStat = await stat(tarPath);
160
+ const archive = await open(tarPath, 'r');
161
+ let offset = 0;
162
+ let importedFiles = 0;
163
+
164
+ try {
165
+ while (offset + TAR_BLOCK_SIZE <= archiveStat.size) {
166
+ const header = Buffer.alloc(TAR_BLOCK_SIZE);
167
+ const { bytesRead } = await archive.read(header, 0, TAR_BLOCK_SIZE, offset);
168
+ if (bytesRead !== TAR_BLOCK_SIZE) throw new Error('Backup archive ended unexpectedly.');
169
+ if (header.every((byte) => byte === 0)) break;
170
+ verifyTarHeader(header);
171
+
172
+ const name = nullTerminatedString(header, 0, 100);
173
+ const prefix = nullTerminatedString(header, 345, 155);
174
+ const archivePath = prefix ? `${prefix}/${name}` : name;
175
+ const type = String.fromCharCode(header[156] || 48);
176
+ const size = parseOctal(header, 124, 12);
177
+ const dataStart = offset + TAR_BLOCK_SIZE;
178
+ const dataEnd = dataStart + size;
179
+ if (dataEnd > archiveStat.size) throw new Error('Backup file data exceeds the archive size.');
180
+
181
+ const target = safeArchiveTarget(stagingDirectory, archivePath);
182
+ if (type === '5') {
183
+ await mkdir(target, { recursive: true });
184
+ } else if (type === '0') {
185
+ importedFiles += 1;
186
+ if (importedFiles > MAX_IMPORTED_FILES) throw new Error('Backup contains too many files.');
187
+ await mkdir(dirname(target), { recursive: true });
188
+ if (size === 0) {
189
+ await writeFile(target, Buffer.alloc(0), { mode: 0o600 });
190
+ } else {
191
+ await pipeline(
192
+ createReadStream(tarPath, { start: dataStart, end: dataEnd - 1 }),
193
+ createWriteStream(target, { mode: 0o600 })
194
+ );
195
+ }
196
+ } else {
197
+ throw new Error('Backup contains an unsupported link or special file.');
198
+ }
199
+
200
+ offset = dataStart + Math.ceil(size / TAR_BLOCK_SIZE) * TAR_BLOCK_SIZE;
201
+ }
202
+ } finally {
203
+ await archive.close();
204
+ }
205
+
206
+ return importedFiles;
207
+ }
208
+
209
+ async function mergeDirectory(source, destination) {
210
+ await mkdir(destination, { recursive: true });
211
+ const entries = await readdir(source, { withFileTypes: true });
212
+ for (const entry of entries) {
213
+ const sourcePath = join(source, entry.name);
214
+ const destinationPath = join(destination, entry.name);
215
+ if (entry.isDirectory()) {
216
+ await mergeDirectory(sourcePath, destinationPath);
217
+ } else if (entry.isFile()) {
218
+ await mkdir(dirname(destinationPath), { recursive: true });
219
+ await copyFile(sourcePath, destinationPath);
220
+ }
221
+ }
222
+ }
223
+
224
+ router.get('/stats', async (_req, res, next) => {
225
+ try {
226
+ res.json(await dataDirectoryStats());
227
+ } catch (error) {
228
+ next(error);
229
+ }
230
+ });
231
+
232
+ router.get('/export', async (_req, res, next) => {
233
+ try {
234
+ const files = await collectFiles(paths.dataDir);
235
+ const date = new Date().toISOString().slice(0, 10);
236
+ res.setHeader('Content-Type', 'application/gzip');
237
+ res.setHeader('Content-Disposition', `attachment; filename="devbuddy-data-${date}.tar.gz"`);
238
+
239
+ const gzip = createGzip({ level: 6 });
240
+ gzip.pipe(res);
241
+ for (const file of files) {
242
+ await writeWithBackpressure(gzip, createTarHeader(file));
243
+ const readOptions = file.size > 0 ? { end: file.size - 1 } : undefined;
244
+ for await (const chunk of createReadStream(file.absolutePath, readOptions)) {
245
+ await writeWithBackpressure(gzip, chunk);
246
+ }
247
+ const padding = (TAR_BLOCK_SIZE - (file.size % TAR_BLOCK_SIZE)) % TAR_BLOCK_SIZE;
248
+ if (padding) await writeWithBackpressure(gzip, Buffer.alloc(padding));
249
+ }
250
+ await writeWithBackpressure(gzip, Buffer.alloc(TAR_BLOCK_SIZE * 2));
251
+ gzip.end();
252
+ } catch (error) {
253
+ if (!res.headersSent) next(error);
254
+ else res.destroy(error);
255
+ }
256
+ });
257
+
258
+ router.post('/import', async (req, res, next) => {
259
+ const temporaryDirectory = await mkdtemp(join(tmpdir(), 'devbuddy-backup-'));
260
+ const tarPath = join(temporaryDirectory, 'backup.tar');
261
+ const stagingDirectory = join(temporaryDirectory, 'data');
262
+
263
+ try {
264
+ await mkdir(stagingDirectory, { recursive: true });
265
+ await pipeline(
266
+ req,
267
+ byteLimit(MAX_COMPRESSED_BYTES, 'Compressed backup'),
268
+ createGunzip(),
269
+ byteLimit(MAX_EXPANDED_BYTES, 'Expanded backup'),
270
+ createWriteStream(tarPath, { mode: 0o600 })
271
+ );
272
+ const importedFiles = await extractTar(tarPath, stagingDirectory);
273
+ await mergeDirectory(stagingDirectory, paths.dataDir);
274
+ res.json({
275
+ importedFiles,
276
+ ...(await dataDirectoryStats())
277
+ });
278
+ } catch (error) {
279
+ next(error);
280
+ } finally {
281
+ await rm(temporaryDirectory, { recursive: true, force: true }).catch(() => {});
282
+ }
283
+ });
284
+
285
+ export default router;
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync } from 'node:fs';
2
2
  import { Router } from 'express';
3
3
  import { paths } from '../config.js';
4
- import { saveAccessToken, saveClipboardDeduplicateMinutes, saveClipboardEnabled, saveClipboardImageEnabled, saveDefaultBrowser, saveDefaultEditor, saveDomain, saveJiraIssuePrefix, settingsStatus } from '../repositories/settings.js';
4
+ import { saveAccessToken, saveClipboardDeduplicateMinutes, saveClipboardEnabled, saveClipboardImageEnabled, saveDefaultBrowser, saveDefaultEditor, saveDomain, saveJiraIssuePrefix, saveTheme, settingsStatus } from '../repositories/settings.js';
5
5
  import { openInSystemBrowser, openInSystemEditor, openInSystemFolder } from '../services/browser.js';
6
6
  import { selectDirectory } from '../services/dialog.js';
7
7
 
@@ -28,6 +28,11 @@ router.put('/default-browser', (req, res) => {
28
28
  if (typeof defaultBrowser !== 'string') return res.status(400).json({ error: 'defaultBrowser must be a string.' });
29
29
  res.json(saveDefaultBrowser(defaultBrowser.trim()));
30
30
  });
31
+ router.put('/theme', (req, res) => {
32
+ const { theme } = req.body || {};
33
+ if (!['dark', 'light'].includes(theme)) return res.status(400).json({ error: 'Theme must be dark or light.' });
34
+ res.json(saveTheme(theme));
35
+ });
31
36
  router.post('/open-url', (req, res) => {
32
37
  const { url } = req.body || {};
33
38
  if (typeof url !== 'string' || !url) return res.status(400).json({ error: 'URL must be a non-empty string.' });
@@ -0,0 +1,140 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { promisify } from 'node:util';
5
+ import { Router } from 'express';
6
+ import { root } from '../config.js';
7
+
8
+ const router = Router();
9
+ const execFileAsync = promisify(execFile);
10
+ const packageJson = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
11
+ const PACKAGE_NAME = packageJson.name;
12
+ const CURRENT_VERSION = packageJson.version;
13
+ const CACHE_TTL_MS = 15 * 60 * 1000;
14
+
15
+ let cachedResult = null;
16
+ let cachedAt = 0;
17
+
18
+ function versionParts(version) {
19
+ return String(version || '')
20
+ .trim()
21
+ .replace(/^v/i, '')
22
+ .split('-')[0]
23
+ .split('.')
24
+ .map((part) => Number.parseInt(part, 10) || 0);
25
+ }
26
+
27
+ function compareVersions(left, right) {
28
+ const a = versionParts(left);
29
+ const b = versionParts(right);
30
+ const length = Math.max(a.length, b.length);
31
+ for (let index = 0; index < length; index += 1) {
32
+ const difference = (a[index] || 0) - (b[index] || 0);
33
+ if (difference !== 0) return difference;
34
+ }
35
+ return 0;
36
+ }
37
+
38
+ function normalizeChangelogEntry(entry) {
39
+ if (!entry || typeof entry !== 'object' || !entry.version) return null;
40
+ return {
41
+ version: String(entry.version).replace(/^v/i, ''),
42
+ name: entry.name || 'Release',
43
+ notes: Array.isArray(entry.notes) ? entry.notes.map((note) => `• ${note}`).join('\n') : String(entry.notes || ''),
44
+ publishedAt: entry.publishedAt || null
45
+ };
46
+ }
47
+
48
+ function buildReleaseList(metadata) {
49
+ const localEntries = Array.isArray(packageJson.devbuddyChangelog)
50
+ ? packageJson.devbuddyChangelog.map(normalizeChangelogEntry).filter(Boolean)
51
+ : [];
52
+ const remoteEntries = Array.isArray(metadata.devbuddyChangelog)
53
+ ? metadata.devbuddyChangelog.map(normalizeChangelogEntry).filter(Boolean)
54
+ : [];
55
+ const entriesByVersion = new Map();
56
+
57
+ [...remoteEntries, ...localEntries].forEach((entry) => {
58
+ if (!entriesByVersion.has(entry.version)) entriesByVersion.set(entry.version, entry);
59
+ });
60
+
61
+ if (metadata.time && typeof metadata.time === 'object') {
62
+ Object.entries(metadata.time).forEach(([version, publishedAt]) => {
63
+ if (['created', 'modified'].includes(version) || !/^\d+\.\d+\.\d+/.test(version)) return;
64
+ const existing = entriesByVersion.get(version);
65
+ if (existing) {
66
+ if (!existing.publishedAt) existing.publishedAt = publishedAt;
67
+ } else {
68
+ entriesByVersion.set(version, {
69
+ version,
70
+ name: 'Release',
71
+ notes: 'Published to the configured npm registry.',
72
+ publishedAt
73
+ });
74
+ }
75
+ });
76
+ }
77
+
78
+ return [...entriesByVersion.values()]
79
+ .sort((left, right) => compareVersions(right.version, left.version))
80
+ .slice(0, 10);
81
+ }
82
+
83
+ async function readNpmPackageMetadata() {
84
+ const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
85
+ const { stdout } = await execFileAsync(
86
+ npmCommand,
87
+ ['view', `${PACKAGE_NAME}@latest`, 'version', 'time', 'devbuddyChangelog', '--json'],
88
+ {
89
+ timeout: 8000,
90
+ maxBuffer: 1024 * 1024,
91
+ windowsHide: true
92
+ }
93
+ );
94
+ const metadata = JSON.parse(stdout);
95
+ if (!metadata || typeof metadata !== 'object' || !metadata.version) {
96
+ throw new Error('The configured npm registry did not return package version metadata.');
97
+ }
98
+ return metadata;
99
+ }
100
+
101
+ async function checkForUpdates() {
102
+ const metadata = await readNpmPackageMetadata();
103
+ const latestVersion = String(metadata.version).replace(/^v/i, '');
104
+ return {
105
+ currentVersion: CURRENT_VERSION,
106
+ latestVersion,
107
+ updateAvailable: compareVersions(latestVersion, CURRENT_VERSION) > 0,
108
+ checkedAt: new Date().toISOString(),
109
+ releases: buildReleaseList(metadata),
110
+ sourceUnavailable: false
111
+ };
112
+ }
113
+
114
+ function unavailableResult() {
115
+ return {
116
+ currentVersion: CURRENT_VERSION,
117
+ latestVersion: CURRENT_VERSION,
118
+ updateAvailable: false,
119
+ checkedAt: new Date().toISOString(),
120
+ releases: buildReleaseList({}),
121
+ sourceUnavailable: true
122
+ };
123
+ }
124
+
125
+ router.get('/', async (req, res) => {
126
+ const forceRefresh = req.query.refresh === '1';
127
+ if (!forceRefresh && cachedResult && Date.now() - cachedAt < CACHE_TTL_MS) {
128
+ return res.json(cachedResult);
129
+ }
130
+
131
+ try {
132
+ cachedResult = await checkForUpdates();
133
+ } catch {
134
+ cachedResult = unavailableResult();
135
+ }
136
+ cachedAt = Date.now();
137
+ return res.json(cachedResult);
138
+ });
139
+
140
+ export default router;
package/server.js CHANGED
@@ -21,6 +21,8 @@ import postmanRoutes from './server/routes/postman.js';
21
21
  import bookmarkSyncRoutes from './server/routes/bookmark-sync.js';
22
22
  import fileOrganizerRoutes from './server/routes/file-organizer.js';
23
23
  import branchSyncRoutes from './server/routes/branch-sync.js';
24
+ import updateRoutes from './server/routes/updates.js';
25
+ import dataBackupRoutes from './server/routes/data-backup.js';
24
26
  import { addErrorRecord } from './server/repositories/errors.js';
25
27
  import { startClipboardCapture } from './server/services/clipboard-history.js';
26
28
 
@@ -77,6 +79,8 @@ app.use('/api/postman', postmanRoutes);
77
79
  app.use('/api/bookmark-sync', bookmarkSyncRoutes);
78
80
  app.use('/api/file-organizer', fileOrganizerRoutes);
79
81
  app.use('/api/branch-sync', branchSyncRoutes);
82
+ app.use('/api/updates', updateRoutes);
83
+ app.use('/api/data-backup', dataBackupRoutes);
80
84
 
81
85
  app.use((err, req, res, _next) => {
82
86
  addErrorRecord({