teqo-backup/scripts/lib/reel-ingest.mjs

325 lines
11 KiB
JavaScript

/**
* C195 — pure rules of `pnpm reels:ingest`: CLI parsing, the fail-closed target
* guard and the package reader/validator. No Payload and no `server-only`: the
* CLI (`scripts/reels-ingest.mjs`) and the int spec share it, and the unit pin
* covers the refusal matrix.
*
* The package contract itself (filenames, kinds, required flag, metadata keys,
* stored names/mimetypes/alts) lives in `src/lib/reel.ts`, the owner of the
* reel vocabulary that the production skill and the writer also read.
*/
import { readFile, readdir, stat } from 'node:fs/promises'
import { join } from 'node:path'
import {
REEL_FEATURES,
REEL_PACKAGE_ARTIFACTS,
REEL_PACKAGE_METADATA_FILENAME,
REEL_PACKAGE_METADATA_KEYS,
REEL_PACKAGE_TRANSCRIPT_FILENAME,
REEL_TITLE_MAX_LENGTH,
reelMediaFieldByKind,
} from '../../src/lib/reel.ts'
import {
ALLOW_REMOTE_DB_FLAG,
TEST_DATABASE_NAME_RE,
databaseHostname,
databaseName,
isLocalDatabaseUrl,
isTruthyEnv,
parseEqualsFlags,
} from './cli.mjs'
/** Explicit write-intent flag; dry-run (the default) never needs it. */
export const REELS_INGEST_CONFIRM_FLAG = 'REELS_INGEST_CONFIRM'
const USAGE = 'uso: `pnpm reels:ingest <diretório> [--apply]`'
/**
* Declared environment → exact database name. The name is the honest
* discriminator on the homeserver: the socat proxy rewrites the host to
* 127.0.0.1 and `NODE_ENV=production` is set in both env files, so
* `assertLocalDatabase` alone cannot tell staging from production.
*/
const REEL_INGEST_DATABASE_BY_ENV = Object.freeze({
staging: 'teqo_staging',
production: 'teqo_1313',
})
/** Identity hash of the shot list written by the production skill (sha256 hex). */
const REEL_INGEST_SHOT_LIST_HASH_RE = /^[a-f0-9]{64}$/
/**
* Fail-closed write gate: `--apply` only proceeds with the explicit intent
* flag. Pure and injectable so the unit pin covers the refusal.
*
* @param {{ apply?: boolean, confirm?: boolean }} [options]
*/
export const assertReelIngestApplyConfirm = ({
apply = false,
confirm = isTruthyEnv(process.env[REELS_INGEST_CONFIRM_FLAG]),
} = {}) => {
if (!apply || confirm) return
throw new Error(
'modo --apply ESCREVE na biblioteca — exige confirmação explícita de intenção.\n' +
` Re-rodar com: ${REELS_INGEST_CONFIRM_FLAG}=1 pnpm reels:ingest <diretório> --apply\n` +
' (sem --apply o comando só confere e mostra o que seria criado ou atualizado).',
)
}
/**
* `pnpm reels:ingest <diretório> [--apply]` argv parser. The directory is the
* one positional; `--apply` is the only flag (bare, no value). Errors carry the
* usage so the CLI can `die` with them.
*
* @param {string[]} [argv]
* @returns {{ directory: string, apply: boolean }}
*/
export const parseReelIngestArgs = (argv = process.argv.slice(2)) => {
const { flags, positional } = parseEqualsFlags(argv)
const unknown = Object.keys(flags).filter((flag) => flag !== 'apply')
if (unknown.length > 0) {
throw new Error(
`argumento desconhecido: ${unknown.map((flag) => `--${flag}`).join(', ')} — ${USAGE}.`,
)
}
if (flags.apply !== undefined && flags.apply !== true) {
throw new Error(`--apply não aceita valor — ${USAGE}.`)
}
if (positional.length === 0) {
throw new Error(`informe o diretório do pacote — ${USAGE}.`)
}
if (positional.length > 1) {
throw new Error(
`argumento posicional extra: ${positional.slice(1).join(', ')} — o pacote é um único diretório; ${USAGE}.`,
)
}
return { directory: positional[0], apply: flags.apply === true }
}
/**
* Fail-closed target guard. Outside a test run the declared `TEQO_ENV`
* (`staging`|`production`) is mandatory and must match the exact database name;
* inside vitest only a `teqo*_test` database is accepted. `ALLOW_REMOTE_DB` is
* always refused — the ingest never runs from a workstation against a remote
* database.
*
* @param {{
* databaseUrl?: string | null,
* teqoEnv?: string | null,
* allowRemoteDb?: boolean,
* isTest?: boolean,
* }} [options]
* @returns {{ environment: 'staging' | 'production' | 'test', databaseName: string }}
*/
export const assertReelIngestTarget = ({
databaseUrl = process.env.DATABASE_URL,
teqoEnv = process.env.TEQO_ENV,
allowRemoteDb = isTruthyEnv(process.env[ALLOW_REMOTE_DB_FLAG]),
isTest = process.env.NODE_ENV === 'test' || isTruthyEnv(process.env.VITEST),
} = {}) => {
if (databaseUrl === undefined || databaseUrl === null || String(databaseUrl).trim() === '') {
throw new Error(
'DATABASE_URL ausente — rode no ambiente do alvo (env file do stack) para o guard saber onde escrever.',
)
}
const targetDatabaseName = databaseName(databaseUrl)
let protocol = null
try {
protocol = new URL(String(databaseUrl ?? '')).protocol
} catch {
protocol = null
}
if (protocol !== 'postgresql:') {
throw new Error(
`protocolo "${protocol ?? '(inválido)'}" ≠ "postgresql:" — o ingest só escreve num alvo PostgreSQL.`,
)
}
if (!isLocalDatabaseUrl(databaseUrl)) {
// Never echo the URL — it carries the DB password.
throw new Error(
`host "${databaseHostname(databaseUrl) ?? '(inválido)'}" fora do allowlist local — o ingest nunca escreve em alvo remoto.`,
)
}
if (allowRemoteDb) {
throw new Error(
'ALLOW_REMOTE_DB está setado — remova; o ingest não precisa do override remoto.',
)
}
if (isTest) {
if (targetDatabaseName === null || !TEST_DATABASE_NAME_RE.test(targetDatabaseName)) {
throw new Error(
`banco-alvo "${targetDatabaseName ?? '(inválido)'}" não é de teste — o ingest em teste só escreve em "teqo*_test".`,
)
}
return { environment: 'test', databaseName: targetDatabaseName }
}
if (teqoEnv !== 'staging' && teqoEnv !== 'production') {
throw new Error(
`TEQO_ENV="${teqoEnv ?? '(ausente)'}" — declare o ambiente do alvo: TEQO_ENV=staging ou TEQO_ENV=production.`,
)
}
const expected = REEL_INGEST_DATABASE_BY_ENV[teqoEnv]
if (targetDatabaseName !== expected) {
throw new Error(
`banco-alvo "${targetDatabaseName ?? '(inválido)'}" ≠ "${expected}" para TEQO_ENV="${teqoEnv}" — ` +
'o comando recusa escrever num alvo que não corresponde ao ambiente declarado.',
)
}
return { environment: teqoEnv, databaseName: targetDatabaseName }
}
/**
* All reasons a parsed metadata + file listing is not a valid package. Empty
* array means valid; the reader joins the list into one error.
*
* @param {{ metadata: unknown, fileNames: Set<string> }} input
* @returns {string[]}
*/
export const reelPackageIssues = ({ metadata, fileNames }) => {
if (metadata === null || typeof metadata !== 'object' || Array.isArray(metadata)) {
return [`${REEL_PACKAGE_METADATA_FILENAME} deve conter um objeto JSON.`]
}
const issues = []
const keys = REEL_PACKAGE_METADATA_KEYS
const title = metadata[keys.title]
if (typeof title !== 'string' || title.trim() === '') {
issues.push(`${keys.title} ausente ou vazio.`)
} else if (title.length > REEL_TITLE_MAX_LENGTH) {
issues.push(`${keys.title} excede ${REEL_TITLE_MAX_LENGTH} caracteres.`)
}
const feature = metadata[keys.feature]
if (!REEL_FEATURES.includes(feature)) {
issues.push(
`${keys.feature} "${String(feature)}" fora do vocabulário (${REEL_FEATURES.join(', ')}).`,
)
}
const shotListHash = metadata[keys.shotListHash]
if (typeof shotListHash !== 'string' || !REEL_INGEST_SHOT_LIST_HASH_RE.test(shotListHash)) {
issues.push(`${keys.shotListHash} deve ser um sha256 hex (64 caracteres minúsculos).`)
}
const coverAlt = metadata[keys.coverAlt]
if (typeof coverAlt !== 'string' || coverAlt.trim() === '') {
issues.push(`${keys.coverAlt} ausente ou vazio.`)
}
const durationSeconds = metadata[keys.durationSeconds]
if (
durationSeconds !== undefined &&
durationSeconds !== null &&
(typeof durationSeconds !== 'number' ||
!Number.isFinite(durationSeconds) ||
durationSeconds <= 0)
) {
issues.push(`${keys.durationSeconds} inválido (segundos positivos).`)
}
const createdAt = metadata[keys.createdAt]
if (
createdAt !== undefined &&
createdAt !== null &&
(typeof createdAt !== 'string' || Number.isNaN(Date.parse(createdAt)))
) {
issues.push(`${keys.createdAt} inválido (data ISO 8601).`)
}
for (const artifact of REEL_PACKAGE_ARTIFACTS) {
if (artifact.required && !fileNames.has(artifact.filename)) {
issues.push(`artefato obrigatório ausente: ${artifact.filename}.`)
}
}
return issues
}
/**
* Reads and validates one package directory. Throws with every pending item so
* the operator fixes the package in one pass; never touches the database.
*
* @param {string} directory
* @returns {Promise<{
* directory: string,
* metadata: Record<string, unknown>,
* artifacts: Array<{
* filename: string,
* kind: string,
* field: string,
* required: boolean,
* present: boolean,
* path: string,
* }>,
* transcriptPath: string | null,
* }>}
*/
export const readReelPackage = async (directory) => {
const directoryStat = await stat(directory).catch(() => null)
if (!directoryStat?.isDirectory()) {
throw new Error(`diretório do pacote inválido: ${directory}`)
}
const entries = await readdir(directory, { withFileTypes: true })
const fileNames = new Set(entries.filter((entry) => entry.isFile()).map((entry) => entry.name))
if (!fileNames.has(REEL_PACKAGE_METADATA_FILENAME)) {
throw new Error(`pacote incompleto: ${REEL_PACKAGE_METADATA_FILENAME} não encontrado.`)
}
let metadata
try {
metadata = JSON.parse(await readFile(join(directory, REEL_PACKAGE_METADATA_FILENAME), 'utf8'))
} catch (error) {
throw new Error(
`${REEL_PACKAGE_METADATA_FILENAME} ilegível: ${error instanceof Error ? error.message : String(error)}`,
)
}
const issues = reelPackageIssues({ metadata, fileNames })
if (issues.length > 0) {
throw new Error(`pacote inválido:\n - ${issues.join('\n - ')}`)
}
const artifacts = REEL_PACKAGE_ARTIFACTS.map((artifact) => ({
...artifact,
field: reelMediaFieldByKind[artifact.kind],
present: fileNames.has(artifact.filename),
path: join(directory, artifact.filename),
}))
const emptyArtifacts = []
for (const artifact of artifacts) {
if (!artifact.present) continue
const artifactStat = await stat(artifact.path).catch(() => null)
if (artifactStat === null || !artifactStat.isFile() || artifactStat.size === 0) {
emptyArtifacts.push(artifact.filename)
}
}
if (emptyArtifacts.length > 0) {
throw new Error(
`pacote inválido:\n - artefato vazio ou ilegível: ${emptyArtifacts.join(', ')}.`,
)
}
return {
directory,
metadata,
artifacts,
transcriptPath: fileNames.has(REEL_PACKAGE_TRANSCRIPT_FILENAME)
? join(directory, REEL_PACKAGE_TRANSCRIPT_FILENAME)
: null,
}
}