@meith/backup 0.34.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/src/restore.ts ADDED
@@ -0,0 +1,332 @@
1
+ import { copyFile, mkdir, mkdtemp, readdir, readFile, rm, stat } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import path from 'node:path'
4
+
5
+ import type { FileStore } from '@meith/core'
6
+ import { ValidationError } from '@meith/core'
7
+ import { compareVersions } from '@meith/upgrade'
8
+
9
+ import { inspectArchive, type RestoreLimits } from './archive'
10
+ import { type BackupManifest, parseManifest } from './bundle'
11
+ import { type BackupLog, SILENT_LOG } from './create'
12
+ import { postgresClientEnvironment, run } from './postgres-client'
13
+ import { uploadDirectoryToStore } from './uploads'
14
+
15
+ export type RestoreTargetMode = 'empty-database' | 'reset-schema'
16
+
17
+ export interface RestoreTarget {
18
+ readonly url: string
19
+ readonly variable: string
20
+ readonly mode: RestoreTargetMode
21
+ }
22
+
23
+ export type RestoreUploadsPlan =
24
+ | { readonly mode: 'skip' }
25
+ | { readonly mode: 'directory'; readonly dir: string }
26
+ | {
27
+ readonly mode: 'store'
28
+ readonly store: { put: FileStore['put'] }
29
+ readonly description: string
30
+ }
31
+
32
+ export interface RestoreInput {
33
+ readonly bundle: string
34
+ readonly target: RestoreTarget
35
+ readonly codeVersion: string
36
+ readonly migrate: (url: string) => Promise<number>
37
+ readonly uploads: RestoreUploadsPlan
38
+ readonly limits: RestoreLimits
39
+ readonly log?: BackupLog | undefined
40
+ }
41
+
42
+ export interface RestoreOutcome {
43
+ readonly manifest: BackupManifest
44
+ readonly migrationsApplied: number
45
+ readonly posts: number
46
+ readonly uploads: 'restored' | 'pushed' | 'skipped' | 'none'
47
+ readonly pushed: number
48
+ }
49
+
50
+ export function versionRefusal(manifestVersion: string, codeVersion: string): string | null {
51
+ let order: number
52
+ try {
53
+ order = compareVersions(manifestVersion, codeVersion)
54
+ } catch {
55
+ return null
56
+ }
57
+ if (order <= 0) return null
58
+ return (
59
+ `This bundle was taken by version ${manifestVersion} and this build is ${codeVersion}. ` +
60
+ 'Migrations are forward-only, so a newer dump cannot be restored into older code: ' +
61
+ 'deploy that version or newer first, then restore.'
62
+ )
63
+ }
64
+
65
+ async function validateUploadsArchive(stage: string, limits: RestoreLimits): Promise<void> {
66
+ const members = await inspectArchive(
67
+ path.join(stage, 'uploads.tar.gz'),
68
+ limits,
69
+ new Set(['-', 'd']),
70
+ )
71
+ for (const member of members) {
72
+ if (member.name === '.' && member.type !== 'd') {
73
+ throw new ValidationError('The uploads archive root is not a directory.')
74
+ }
75
+ }
76
+ }
77
+
78
+ async function refuseNonEmptyDirectory(dir: string): Promise<void> {
79
+ const existing = await readdir(dir).catch((error: NodeJS.ErrnoException) => {
80
+ if (error.code === 'ENOENT') return undefined
81
+ throw error
82
+ })
83
+ if (existing !== undefined && existing.length > 0) {
84
+ throw new ValidationError(
85
+ `${dir} is not empty. Empty it first, or on the command line restore the uploads into ` +
86
+ 'a fresh directory with --uploads-dir, the same way the database goes into a fresh ' +
87
+ 'database.',
88
+ )
89
+ }
90
+ }
91
+
92
+ async function extractUploads(
93
+ stage: string,
94
+ dir: string,
95
+ limits: RestoreLimits,
96
+ log: BackupLog,
97
+ ): Promise<void> {
98
+ await validateUploadsArchive(stage, limits)
99
+ await refuseNonEmptyDirectory(dir)
100
+ await mkdir(dir, { recursive: true })
101
+ await run('tar', ['xzf', path.join(stage, 'uploads.tar.gz'), '-C', dir])
102
+ log.info(`Restored the uploads into ${dir}.`)
103
+ }
104
+
105
+ async function pushUploadsToStore(
106
+ stage: string,
107
+ limits: RestoreLimits,
108
+ store: { put: FileStore['put'] },
109
+ description: string,
110
+ log: BackupLog,
111
+ ): Promise<number> {
112
+ await validateUploadsArchive(stage, limits)
113
+ const dir = path.join(stage, 'uploads-extract')
114
+ await mkdir(dir, { recursive: true })
115
+ await run('tar', ['xzf', path.join(stage, 'uploads.tar.gz'), '-C', dir])
116
+
117
+ const pushed = await uploadDirectoryToStore(store, dir)
118
+ log.info(`Uploaded ${pushed} object(s) to ${description}.`)
119
+ return pushed
120
+ }
121
+
122
+ const FAIL_INHERITED_RUNS_SQL =
123
+ "update backup_runs set status = 'failed', finished_at = now(), heartbeat_at = now(), " +
124
+ "error = 'The backup was interrupted: the dump this board was restored from caught it " +
125
+ "mid-flight.' where status in ('queued', 'running') returning id"
126
+
127
+ const RELEASE_INHERITED_LEASES_SQL =
128
+ 'update tasks set locked_until = null where locked_until is not null returning key'
129
+
130
+ async function tableExists(name: string, databaseEnvironment: NodeJS.ProcessEnv): Promise<boolean> {
131
+ const output = await run(
132
+ 'psql',
133
+ ['-tAc', `select to_regclass('public.${name}') is not null`],
134
+ databaseEnvironment,
135
+ )
136
+ return output.trim() === 't'
137
+ }
138
+
139
+ function returnedRows(output: string): number {
140
+ return output.split('\n').filter((line) => line.trim() !== '').length
141
+ }
142
+
143
+ async function failInheritedRuns(databaseEnvironment: NodeJS.ProcessEnv): Promise<number> {
144
+ if (await tableExists('tasks', databaseEnvironment)) {
145
+ await run('psql', ['-tAc', RELEASE_INHERITED_LEASES_SQL], databaseEnvironment)
146
+ }
147
+ if (!(await tableExists('backup_runs', databaseEnvironment))) return 0
148
+ return returnedRows(await run('psql', ['-tAc', FAIL_INHERITED_RUNS_SQL], databaseEnvironment))
149
+ }
150
+
151
+ const RESET_SCHEMA_SQL = [
152
+ 'drop schema if exists drizzle cascade;',
153
+ 'drop schema if exists public cascade;',
154
+ 'create schema public;',
155
+ ].join('\n')
156
+
157
+ async function prepareTarget(
158
+ target: RestoreTarget,
159
+ databaseEnvironment: NodeJS.ProcessEnv,
160
+ log: BackupLog,
161
+ ): Promise<void> {
162
+ const tables = (
163
+ await run(
164
+ 'psql',
165
+ ['-tAc', "select count(*) from information_schema.tables where table_schema = 'public'"],
166
+ databaseEnvironment,
167
+ )
168
+ ).trim()
169
+
170
+ if (target.mode === 'empty-database') {
171
+ if (tables !== '0') {
172
+ throw new ValidationError(
173
+ `The target database already holds ${tables} table(s). Restore into a new, ` +
174
+ 'empty database — a restore over a live board is how a bad backup becomes ' +
175
+ 'two lost boards.',
176
+ )
177
+ }
178
+ return
179
+ }
180
+
181
+ const members = (
182
+ await run(
183
+ 'psql',
184
+ [
185
+ '-tAc',
186
+ "select case when to_regclass('public.users') is null then 0 " +
187
+ 'else (select count(*) from users) end',
188
+ ],
189
+ databaseEnvironment,
190
+ )
191
+ ).trim()
192
+ if (members !== '0') {
193
+ throw new ValidationError(
194
+ `The target database holds ${members} member account(s). The installer only restores ` +
195
+ 'over an empty, uninstalled board.',
196
+ )
197
+ }
198
+
199
+ if (tables !== '0') {
200
+ log.info(`Dropping the empty schema (${tables} table(s)) before the restore…`)
201
+ }
202
+ await run('psql', ['-v', 'ON_ERROR_STOP=1', '-q'], databaseEnvironment, RESET_SCHEMA_SQL)
203
+ }
204
+
205
+ export async function restoreBackup(input: RestoreInput): Promise<RestoreOutcome> {
206
+ const log = input.log ?? SILENT_LOG
207
+ const databaseEnvironment = postgresClientEnvironment(input.target.url, input.target.variable)
208
+
209
+ const bundleInfo = await stat(input.bundle).catch(() => undefined)
210
+ if (bundleInfo === undefined || !bundleInfo.isFile()) {
211
+ throw new ValidationError(`No such bundle: ${input.bundle}`)
212
+ }
213
+ if (bundleInfo.size > input.limits.archiveBytes) {
214
+ throw new ValidationError('The backup bundle exceeds MEITH_RESTORE_MAX_ARCHIVE_BYTES.')
215
+ }
216
+
217
+ const stage = await mkdtemp(path.join(tmpdir(), 'meith-restore-'))
218
+ try {
219
+ const stagedBundle = path.join(stage, 'bundle.tar.gz')
220
+ await copyFile(path.resolve(input.bundle), stagedBundle)
221
+ const members = await inspectArchive(stagedBundle, input.limits, new Set(['-']))
222
+ const possibleMembers = new Set(['manifest.json', 'db.dump', 'uploads.tar.gz'])
223
+ if (members.some((member) => !possibleMembers.has(member.name))) {
224
+ throw new ValidationError('The backup bundle contains an unexpected member.')
225
+ }
226
+ await run('tar', ['xzf', stagedBundle, '-C', stage, 'manifest.json'])
227
+ const manifest = parseManifest(await readFile(path.join(stage, 'manifest.json'), 'utf8'))
228
+ const expectedMembers = new Set(['manifest.json', 'db.dump'])
229
+ if (manifest.uploads === 'included') expectedMembers.add('uploads.tar.gz')
230
+ if (
231
+ members.length !== expectedMembers.size ||
232
+ members.some((member) => !expectedMembers.has(member.name))
233
+ ) {
234
+ throw new ValidationError('The backup bundle members do not match its manifest.')
235
+ }
236
+
237
+ const refusal = versionRefusal(manifest.version, input.codeVersion)
238
+ if (refusal !== null) throw new ValidationError(refusal)
239
+
240
+ const restoreMembers = ['db.dump']
241
+ if (manifest.uploads === 'included') restoreMembers.push('uploads.tar.gz')
242
+ await run('tar', ['xzf', stagedBundle, '-C', stage, ...restoreMembers])
243
+ if (manifest.uploads === 'included') {
244
+ await validateUploadsArchive(stage, input.limits)
245
+ if (input.uploads.mode === 'directory') await refuseNonEmptyDirectory(input.uploads.dir)
246
+ }
247
+
248
+ await prepareTarget(input.target, databaseEnvironment, log)
249
+
250
+ log.info(`Restoring the backup taken ${manifest.createdAt} (version ${manifest.version})…`)
251
+ await run(
252
+ 'pg_restore',
253
+ [
254
+ '--no-owner',
255
+ '--no-privileges',
256
+ '--dbname',
257
+ databaseEnvironment.PGDATABASE ?? '',
258
+ path.join(stage, 'db.dump'),
259
+ ],
260
+ databaseEnvironment,
261
+ )
262
+
263
+ let step = 'applying the migrations the dump predates'
264
+ let migrationsApplied = 0
265
+ let posts = 0
266
+ let uploads: RestoreOutcome['uploads'] = 'none'
267
+ let pushed = 0
268
+ try {
269
+ migrationsApplied = await input.migrate(input.target.url)
270
+ log.info(
271
+ migrationsApplied === 0
272
+ ? 'Migrations: nothing to do — the dump matches this build.'
273
+ : `Migrations: applied ${migrationsApplied} migration(s) the dump predates.`,
274
+ )
275
+
276
+ const interrupted = await failInheritedRuns(databaseEnvironment)
277
+ if (interrupted > 0) {
278
+ log.info(`Marked ${interrupted} backup run(s) the dump caught mid-flight as failed.`)
279
+ }
280
+
281
+ posts = Number(
282
+ (await run('psql', ['-tAc', 'select count(*) from posts'], databaseEnvironment)).trim(),
283
+ )
284
+ log.info(`The restored board holds ${posts} post(s).`)
285
+
286
+ step = 'putting the uploads back'
287
+ if (manifest.uploads === 'included') {
288
+ if (input.uploads.mode === 'skip') {
289
+ uploads = 'skipped'
290
+ } else if (input.uploads.mode === 'store') {
291
+ pushed = await pushUploadsToStore(
292
+ stage,
293
+ input.limits,
294
+ input.uploads.store,
295
+ input.uploads.description,
296
+ log,
297
+ )
298
+ uploads = 'pushed'
299
+ } else {
300
+ await extractUploads(stage, input.uploads.dir, input.limits, log)
301
+ uploads = 'restored'
302
+ }
303
+ }
304
+ } catch (error) {
305
+ throw new ValidationError(
306
+ `The database was restored from ${path.basename(input.bundle)}, but ${step} failed: ${
307
+ error instanceof Error ? error.message : String(error)
308
+ } The board is installed and serving the restored database. ` +
309
+ (step === 'putting the uploads back'
310
+ ? 'Unpack uploads.tar.gz from the bundle into the uploads location by hand.'
311
+ : 'Fix the cause and run `meith migrate`.'),
312
+ )
313
+ }
314
+ if (manifest.uploads !== 'included') {
315
+ log.info(
316
+ manifest.filestore === 's3'
317
+ ? `This bundle carries no uploads — they live in the S3 bucket${
318
+ manifest.bucket === undefined ? '' : ` (${manifest.bucket})`
319
+ }.`
320
+ : manifest.filestore === 'blob'
321
+ ? 'This bundle carries no uploads, and a Vercel Blob store is not ' +
322
+ 'something you can copy out by hand. Take another backup with ' +
323
+ '--uploads include while the old board still exists.'
324
+ : 'This bundle carries no uploads.',
325
+ )
326
+ }
327
+
328
+ return { manifest, migrationsApplied, posts, uploads, pushed }
329
+ } finally {
330
+ await rm(stage, { recursive: true, force: true })
331
+ }
332
+ }
@@ -0,0 +1,45 @@
1
+ import { ValidationError } from '@meith/core'
2
+
3
+ import { bundleTakenAt, isBundleName } from './bundle'
4
+
5
+ export const DEFAULT_KEEP = 7
6
+
7
+ export interface RetentionPolicy {
8
+ readonly keep: number
9
+ readonly keepDays?: number | undefined
10
+ }
11
+
12
+ export function resolveKeep(flag: string | undefined): number {
13
+ if (flag === undefined) return DEFAULT_KEEP
14
+ if (!/^\d+$/.test(flag) || Number(flag) < 1 || !Number.isSafeInteger(Number(flag))) {
15
+ throw new ValidationError(`--keep must be a whole number of bundles, 1 or more, got "${flag}".`)
16
+ }
17
+ return Number(flag)
18
+ }
19
+
20
+ export function pruneCandidates(names: readonly string[], keep: number): readonly string[] {
21
+ return retentionCandidates(names, { keep })
22
+ }
23
+
24
+ export function retentionCandidates(
25
+ names: readonly string[],
26
+ policy: RetentionPolicy,
27
+ now: Date = new Date(),
28
+ ): readonly string[] {
29
+ const bundles = names
30
+ .filter((name) => isBundleName(name))
31
+ .sort()
32
+ .reverse()
33
+ const beyondCount = new Set(bundles.slice(Math.max(1, policy.keep)))
34
+
35
+ const keepDays = policy.keepDays ?? 0
36
+ if (keepDays > 0) {
37
+ const cutoff = now.getTime() - keepDays * 24 * 60 * 60 * 1000
38
+ for (const name of bundles.slice(1)) {
39
+ const takenAt = bundleTakenAt(name)
40
+ if (takenAt !== null && takenAt.getTime() < cutoff) beyondCount.add(name)
41
+ }
42
+ }
43
+
44
+ return bundles.filter((name) => beyondCount.has(name))
45
+ }
package/src/runs.ts ADDED
@@ -0,0 +1,60 @@
1
+ export type BackupTrigger = 'manual' | 'schedule' | 'upgrade' | 'cli'
2
+
3
+ export type BackupRunStatus = 'queued' | 'running' | 'done' | 'incomplete' | 'failed'
4
+
5
+ export interface BackupRunRecord {
6
+ readonly id: number
7
+ readonly trigger: BackupTrigger
8
+ readonly status: BackupRunStatus
9
+ readonly requestedByUserId: number | null
10
+ readonly requestedAt: Date
11
+ readonly startedAt: Date | null
12
+ readonly finishedAt: Date | null
13
+ readonly heartbeatAt: Date | null
14
+ readonly bundleName: string | null
15
+ readonly sizeBytes: number | null
16
+ readonly uploads: 'included' | 'skipped' | null
17
+ readonly shipped: boolean
18
+ readonly skippedKeys: number
19
+ readonly error: string | null
20
+ }
21
+
22
+ export interface BackupRunFinish {
23
+ readonly status: 'done' | 'incomplete' | 'failed'
24
+ readonly finishedAt: Date
25
+ readonly bundleName?: string | null | undefined
26
+ readonly sizeBytes?: number | null | undefined
27
+ readonly uploads?: 'included' | 'skipped' | null | undefined
28
+ readonly shipped?: boolean | undefined
29
+ readonly skippedKeys?: number | undefined
30
+ readonly error?: string | null | undefined
31
+ }
32
+
33
+ export interface BackupRunRepository {
34
+ enqueue(input: {
35
+ readonly trigger: BackupTrigger
36
+ readonly requestedByUserId?: number | null | undefined
37
+ readonly now: Date
38
+ }): Promise<{ readonly id: number; readonly queued: boolean }>
39
+
40
+ claimNext(now: Date): Promise<BackupRunRecord | null>
41
+
42
+ heartbeat(id: number, now: Date): Promise<void>
43
+
44
+ finish(id: number, outcome: BackupRunFinish): Promise<void>
45
+
46
+ active(now: Date, staleBefore: Date): Promise<BackupRunRecord | null>
47
+
48
+ recent(limit: number): Promise<readonly BackupRunRecord[]>
49
+
50
+ lastScheduledAt(): Promise<Date | null>
51
+
52
+ failInterrupted(now: Date, staleBefore: Date): Promise<number>
53
+
54
+ record(input: {
55
+ readonly trigger: BackupTrigger
56
+ readonly requestedByUserId?: number | null | undefined
57
+ readonly startedAt: Date
58
+ readonly outcome: BackupRunFinish
59
+ }): Promise<void>
60
+ }
@@ -0,0 +1,75 @@
1
+ export type BackupFrequency = 'off' | 'daily' | 'weekly'
2
+
3
+ export interface BackupSchedule {
4
+ readonly frequency: BackupFrequency
5
+ readonly hour: number
6
+ readonly minute: number
7
+ readonly weekday: number
8
+ }
9
+
10
+ export const SCHEDULE_TIME_PATTERN = /^([01]\d|2[0-3]):([0-5]\d)$/
11
+
12
+ export function parseScheduleTime(value: string): { hour: number; minute: number } | null {
13
+ const match = SCHEDULE_TIME_PATTERN.exec(value.trim())
14
+ if (match === null) return null
15
+ return { hour: Number(match[1]), minute: Number(match[2]) }
16
+ }
17
+
18
+ export function formatScheduleTime(schedule: { hour: number; minute: number }): string {
19
+ return `${String(schedule.hour).padStart(2, '0')}:${String(schedule.minute).padStart(2, '0')}`
20
+ }
21
+
22
+ const DAY_MS = 24 * 60 * 60 * 1000
23
+
24
+ function slotOnDay(day: Date, schedule: BackupSchedule): Date {
25
+ return new Date(
26
+ Date.UTC(
27
+ day.getUTCFullYear(),
28
+ day.getUTCMonth(),
29
+ day.getUTCDate(),
30
+ schedule.hour,
31
+ schedule.minute,
32
+ ),
33
+ )
34
+ }
35
+
36
+ export function latestSlotAtOrBefore(schedule: BackupSchedule, now: Date): Date | null {
37
+ if (schedule.frequency === 'off') return null
38
+
39
+ for (let back = 0; back < 8; back += 1) {
40
+ const day = new Date(now.getTime() - back * DAY_MS)
41
+ if (schedule.frequency === 'weekly' && day.getUTCDay() !== schedule.weekday) continue
42
+ const slot = slotOnDay(day, schedule)
43
+ if (slot.getTime() <= now.getTime()) return slot
44
+ }
45
+ return null
46
+ }
47
+
48
+ export function nextSlotAfter(schedule: BackupSchedule, from: Date): Date | null {
49
+ if (schedule.frequency === 'off') return null
50
+
51
+ for (let ahead = 0; ahead < 8; ahead += 1) {
52
+ const day = new Date(from.getTime() + ahead * DAY_MS)
53
+ if (schedule.frequency === 'weekly' && day.getUTCDay() !== schedule.weekday) continue
54
+ const slot = slotOnDay(day, schedule)
55
+ if (slot.getTime() > from.getTime()) return slot
56
+ }
57
+ return null
58
+ }
59
+
60
+ export function scheduledBackupDue(
61
+ schedule: BackupSchedule,
62
+ input: {
63
+ readonly now: Date
64
+ readonly lastTickAt: Date | null
65
+ readonly lastScheduledAt: Date | null
66
+ },
67
+ ): Date | null {
68
+ const slot = latestSlotAtOrBefore(schedule, input.now)
69
+ if (slot === null) return null
70
+ if (input.lastTickAt === null || slot.getTime() <= input.lastTickAt.getTime()) return null
71
+ if (input.lastScheduledAt !== null && input.lastScheduledAt.getTime() >= slot.getTime()) {
72
+ return null
73
+ }
74
+ return slot
75
+ }
package/src/uploads.ts ADDED
@@ -0,0 +1,75 @@
1
+ import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+
4
+ import { type FileStore, unusableKeyReason } from '@meith/core'
5
+
6
+ import { contentTypeFor } from './bundle'
7
+
8
+ export interface ListableStore {
9
+ listKeys(): AsyncGenerator<string>
10
+ get(key: string): Promise<Uint8Array | undefined>
11
+ }
12
+
13
+ export interface DrainedStore {
14
+ readonly pulled: number
15
+ readonly skipped: readonly string[]
16
+ }
17
+
18
+ export async function drainStoreToDirectory(
19
+ store: ListableStore,
20
+ dir: string,
21
+ warn: (line: string) => void = () => undefined,
22
+ ): Promise<DrainedStore> {
23
+ let pulled = 0
24
+ const skipped: string[] = []
25
+
26
+ for await (const key of store.listKeys()) {
27
+ const target = path.resolve(dir, key)
28
+ if (target !== dir && !target.startsWith(dir + path.sep)) {
29
+ warn(`Skipping the object at ${JSON.stringify(key)}: its key escapes ${dir}.`)
30
+ skipped.push(key)
31
+ continue
32
+ }
33
+ const unusable = unusableKeyReason(key)
34
+ if (unusable !== undefined) {
35
+ warn(`Skipping the object at ${JSON.stringify(key)}: its key ${unusable}.`)
36
+ skipped.push(key)
37
+ continue
38
+ }
39
+ const body = await store.get(key)
40
+ if (body === undefined) continue
41
+ await mkdir(path.dirname(target), { recursive: true })
42
+ await writeFile(target, body)
43
+ pulled++
44
+ }
45
+
46
+ return { pulled, skipped }
47
+ }
48
+
49
+ export async function walk(dir: string): Promise<readonly string[]> {
50
+ const files: string[] = []
51
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
52
+ const full = path.join(dir, entry.name)
53
+ if (entry.isDirectory()) files.push(...(await walk(full)))
54
+ else if (entry.isFile()) files.push(full)
55
+ }
56
+ return files
57
+ }
58
+
59
+ export async function uploadDirectoryToStore(
60
+ store: { put: FileStore['put'] },
61
+ dir: string,
62
+ ): Promise<number> {
63
+ let pushed = 0
64
+
65
+ for (const file of await walk(dir)) {
66
+ const key = path.relative(dir, file).split(path.sep).join('/')
67
+ await store.put(key, await readFile(file), {
68
+ contentType: contentTypeFor(key),
69
+ visibility: 'public',
70
+ })
71
+ pushed++
72
+ }
73
+
74
+ return pushed
75
+ }