|
1 | 1 | // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
2 | 2 |
|
3 | 3 | /** |
4 | | - * `os storage orphans` — what its help says the inventory writes. |
| 4 | + * `os storage orphans` — what its help and its report say the inventory writes. |
5 | 5 | * |
6 | 6 | * The inventory boots read-only (deferred DDL, no seed, no file brought into |
7 | 7 | * existence), but the boot's connect can switch a SQLite file's journal mode, |
8 | 8 | * which rewrites the file's header and no row (measured on a rollback-journal |
9 | 9 | * file: header bytes 18 and 19 from 1,1 to 2,2, the change counter up by one, |
10 | | - * schema and rows byte-identical). So the help claims no row or table, never |
11 | | - * that the command writes nothing. The help names no target, so it carries no |
12 | | - * SQLite note. |
| 10 | + * schema and rows byte-identical). So the help and the report claim no row or |
| 11 | + * table, never that the command writes nothing. The help names no target, so |
| 12 | + * it carries no SQLite note; the report's READ-ONLY line carries it on a |
| 13 | + * SQLite target, and only there. |
| 14 | + * |
| 15 | + * The report case replaces only the boot. The inventory and its formatter |
| 16 | + * (`@objectstack/service-storage`) run for real over an engine with no files. |
13 | 17 | */ |
14 | 18 |
|
15 | | -import { describe, expect, it } from 'vitest'; |
| 19 | +import { afterEach, describe, expect, it, vi } from 'vitest'; |
| 20 | +import { dirname, resolve } from 'node:path'; |
| 21 | +import { fileURLToPath } from 'node:url'; |
16 | 22 | import StorageOrphans from './orphans.js'; |
| 23 | +import { bootSchemaStack } from '../../utils/schema-migrate.js'; |
| 24 | +import { JOURNAL_MODE_SWITCH_NOTE } from '../../utils/sqlite-occupancy.js'; |
| 25 | + |
| 26 | +vi.mock('../../utils/schema-migrate.js', () => ({ bootSchemaStack: vi.fn() })); |
| 27 | +vi.mock('../../utils/data-migration-plugins.js', () => ({ buildDataMigrationPlugins: vi.fn(async () => []) })); |
| 28 | + |
| 29 | +const HERE = dirname(fileURLToPath(import.meta.url)); |
| 30 | +const CLI_ROOT = resolve(HERE, '..', '..', '..'); |
| 31 | +/** oclif builds its whole command table on the first `run()` in a process. */ |
| 32 | +const RUN_TIMEOUT = 60_000; |
| 33 | + |
| 34 | +const SQLITE_CONFIG = { client: 'better-sqlite3', connection: { filename: '/srv/app/.objectstack/data/objectstack.db' } }; |
| 35 | +const POSTGRES_CONFIG = { client: 'pg', connection: 'postgres://db.example.com:5432/app' }; |
| 36 | +const READ_ONLY_LINE = 'This pass is READ-ONLY: no row or table was written, tombstoned or deleted.'; |
| 37 | + |
| 38 | +/** Wire the mocked boot to an engine that registers both objects and stores no file. */ |
| 39 | +function wireBoot(driverConfig: Record<string, unknown>): void { |
| 40 | + const engine = { |
| 41 | + getObject: (name: string) => (name === 'sys_file' || name === 'sys_attachment' ? { name } : undefined), |
| 42 | + find: async () => [], |
| 43 | + }; |
| 44 | + vi.mocked(bootSchemaStack).mockResolvedValue({ |
| 45 | + // The primary datasource's driver, as `SchemaStack.driver` carries it. |
| 46 | + driver: { config: driverConfig }, |
| 47 | + dbLabel: 'test database', |
| 48 | + kernel: { getService: (name: string) => (name === 'objectql' ? engine : undefined) }, |
| 49 | + tableAbsent: () => false, |
| 50 | + shutdown: async () => {}, |
| 51 | + } as never); |
| 52 | +} |
| 53 | + |
| 54 | +/** One human-mode run; every line `console.log` received. */ |
| 55 | +async function runHuman(): Promise<string[]> { |
| 56 | + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); |
| 57 | + const savedExitCode = process.exitCode; |
| 58 | + try { |
| 59 | + await StorageOrphans.run([], { root: CLI_ROOT }); |
| 60 | + return log.mock.calls.map((c) => c.map(String).join(' ')).join('\n').split('\n'); |
| 61 | + } finally { |
| 62 | + process.exitCode = savedExitCode; |
| 63 | + log.mockRestore(); |
| 64 | + } |
| 65 | +} |
| 66 | + |
| 67 | +afterEach(() => { |
| 68 | + vi.mocked(bootSchemaStack).mockReset(); |
| 69 | +}); |
17 | 70 |
|
18 | 71 | describe('os storage orphans --help', () => { |
19 | 72 | it('says the inventory writes no row or table, tombstones nothing and deletes nothing', () => { |
20 | 73 | expect(StorageOrphans.description).toContain('Writes no row or table, tombstones nothing, deletes nothing.'); |
21 | 74 | expect(StorageOrphans.description).not.toContain('Writes nothing'); |
22 | 75 | }); |
23 | 76 | }); |
| 77 | + |
| 78 | +describe('os storage orphans — the report names no write it cannot vouch for', () => { |
| 79 | + it('on a SQLite file the READ-ONLY line claims no row or table and names the journal-mode switch', async () => { |
| 80 | + wireBoot(SQLITE_CONFIG); |
| 81 | + const lines = await runHuman(); |
| 82 | + const readOnly = lines.find((line) => line.includes('READ-ONLY')); |
| 83 | + expect(readOnly, lines.join('\n')).toBe(`${READ_ONLY_LINE} ${JOURNAL_MODE_SWITCH_NOTE}`); |
| 84 | + expect(lines.join('\n')).not.toContain('nothing was written'); |
| 85 | + }, RUN_TIMEOUT); |
| 86 | + |
| 87 | + it('on a target with no file header (PostgreSQL) the READ-ONLY line claims no row or table and names no switch', async () => { |
| 88 | + wireBoot(POSTGRES_CONFIG); |
| 89 | + const lines = await runHuman(); |
| 90 | + const readOnly = lines.find((line) => line.includes('READ-ONLY')); |
| 91 | + expect(readOnly, lines.join('\n')).toBe(READ_ONLY_LINE); |
| 92 | + expect(lines.join('\n')).not.toContain(JOURNAL_MODE_SWITCH_NOTE); |
| 93 | + }, RUN_TIMEOUT); |
| 94 | +}); |
0 commit comments