Fixes found by the live pull/push e2e: - CRITICAL: driveCycle never checked out the 'docmost' branch before applyPullActions, so Docmost content was written straight onto 'main', clobbering local file edits before push could diff them. Now checkout 'docmost' before pull (applyPullActions commits there then checks out main + merges) — mirrors the engine's pull main(). Round-trip now works both ways. - add an unresolved-merge guard (SPEC §9): skip the cycle if the vault is mid-merge instead of failing on checkout. - SAFETY: enabledSpaces() is now STRICT opt-in — only spaces with settings.gitSync.enabled===true; removed the all-spaces fallback that synced every space (incl. a 92-page one) the moment GIT_SYNC_ENABLED flipped. - SAFETY: per-cycle delete cap (GIT_SYNC_MAX_DELETES_PER_CYCLE, default 5): dry-run the push, and if planned deletes exceed the cap, run the apply with deletePage neutralized — phantom absence-deletions from a non-convergent vault can't soft-delete real pages. Fails safe if the dry-run throws. - fix manual trigger: TriggerGitSyncDto.spaceId needs @IsUUID or the global whitelist ValidationPipe strips it (arrived undefined -> vault 'undefined'). Live-verified on an isolated flagged space: push (vault file edit -> Docmost content, stamped lastUpdatedSource='git-sync') and pull (Docmost rename -> vault file + meta) both work; an unrelated 92-page space stayed untouched throughout. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
239 lines
5.6 KiB
TypeScript
239 lines
5.6 KiB
TypeScript
import {
|
|
IsIn,
|
|
IsNotEmpty,
|
|
IsNotIn,
|
|
IsOptional,
|
|
IsString,
|
|
IsUrl,
|
|
MinLength,
|
|
ValidateIf,
|
|
validateSync,
|
|
} from 'class-validator';
|
|
import { plainToInstance } from 'class-transformer';
|
|
import { IsISO6391 } from '../../common/validators/is-iso6391';
|
|
|
|
export class EnvironmentVariables {
|
|
@IsNotEmpty()
|
|
@IsUrl(
|
|
{
|
|
protocols: ['postgres', 'postgresql'],
|
|
require_tld: false,
|
|
allow_underscores: true,
|
|
},
|
|
{ message: 'DATABASE_URL must be a valid postgres connection string' },
|
|
)
|
|
DATABASE_URL: string;
|
|
|
|
@IsNotEmpty()
|
|
@IsUrl(
|
|
{
|
|
protocols: ['redis', 'rediss'],
|
|
require_tld: false,
|
|
allow_underscores: true,
|
|
},
|
|
{ message: 'REDIS_URL must be a valid redis connection string' },
|
|
)
|
|
REDIS_URL: string;
|
|
|
|
@IsOptional()
|
|
@IsUrl({ protocols: ['http', 'https'], require_tld: false })
|
|
APP_URL: string;
|
|
|
|
@IsNotEmpty()
|
|
@MinLength(32)
|
|
@IsNotIn(['REPLACE_WITH_LONG_SECRET'])
|
|
APP_SECRET: string;
|
|
|
|
@IsOptional()
|
|
@IsIn(['smtp', 'postmark'])
|
|
MAIL_DRIVER: string;
|
|
|
|
@IsOptional()
|
|
@IsIn(['local', 's3', 'azure'])
|
|
STORAGE_DRIVER: string;
|
|
|
|
@IsOptional()
|
|
@ValidateIf((obj) => obj.COLLAB_URL != '' && obj.COLLAB_URL != null)
|
|
@IsUrl({ protocols: ['http', 'https'], require_tld: false })
|
|
COLLAB_URL: string;
|
|
|
|
@IsOptional()
|
|
CLOUD: boolean;
|
|
|
|
@IsOptional()
|
|
@IsUrl(
|
|
{ protocols: [], require_tld: true },
|
|
{
|
|
message:
|
|
'SUBDOMAIN_HOST must be a valid FQDN domain without the http protocol. e.g example.com',
|
|
},
|
|
)
|
|
@ValidateIf((obj) => obj.CLOUD === 'true'.toLowerCase())
|
|
SUBDOMAIN_HOST: string;
|
|
|
|
@IsOptional()
|
|
@IsIn(['database', 'typesense'])
|
|
@IsString()
|
|
SEARCH_DRIVER: string;
|
|
|
|
@IsOptional()
|
|
@IsUrl(
|
|
{
|
|
protocols: ['http', 'https'],
|
|
require_tld: false,
|
|
allow_underscores: true,
|
|
},
|
|
{
|
|
message:
|
|
'TYPESENSE_URL must be a valid typesense url e.g http://localhost:8108',
|
|
},
|
|
)
|
|
@ValidateIf((obj) => obj.SEARCH_DRIVER === 'typesense')
|
|
TYPESENSE_URL: string;
|
|
|
|
@ValidateIf((obj) => obj.SEARCH_DRIVER === 'typesense')
|
|
@IsNotEmpty()
|
|
@IsString()
|
|
TYPESENSE_API_KEY: string;
|
|
|
|
@IsOptional()
|
|
@ValidateIf((obj) => obj.SEARCH_DRIVER === 'typesense')
|
|
@IsISO6391()
|
|
@IsString()
|
|
TYPESENSE_LOCALE: string;
|
|
|
|
@IsOptional()
|
|
@ValidateIf((obj) => obj.AI_DRIVER)
|
|
@IsIn(['openai', 'openai-compatible', 'gemini', 'ollama'])
|
|
@IsString()
|
|
AI_DRIVER: string;
|
|
|
|
@IsOptional()
|
|
@IsString()
|
|
AI_EMBEDDING_MODEL: string;
|
|
|
|
@ValidateIf((obj) => obj.AI_EMBEDDING_DIMENSION)
|
|
@IsIn(['768', '1024', '1536', '2000', '3072'])
|
|
@IsString()
|
|
AI_EMBEDDING_DIMENSION: string;
|
|
|
|
@IsOptional()
|
|
@ValidateIf((obj) => obj.AI_EMBEDDING_SUPPORTS_MRL)
|
|
@IsIn(['true', 'false'])
|
|
@IsString()
|
|
AI_EMBEDDING_SUPPORTS_MRL: string;
|
|
|
|
@ValidateIf((obj) => obj.AI_DRIVER)
|
|
@IsString()
|
|
@IsNotEmpty()
|
|
AI_COMPLETION_MODEL: string;
|
|
|
|
@IsOptional()
|
|
@ValidateIf(
|
|
(obj) =>
|
|
obj.AI_DRIVER && ['openai', 'openai-compatible'].includes(obj.AI_DRIVER),
|
|
)
|
|
@IsString()
|
|
@IsNotEmpty()
|
|
OPENAI_API_KEY: string;
|
|
|
|
@IsOptional()
|
|
@ValidateIf(
|
|
(obj) =>
|
|
obj.AI_DRIVER === 'openai-compatible' ||
|
|
(obj.AI_DRIVER === 'openai' && obj.OPENAI_API_URL),
|
|
)
|
|
@IsUrl({ protocols: ['http', 'https'], require_tld: false })
|
|
OPENAI_API_URL: string;
|
|
|
|
@ValidateIf((obj) => obj.AI_DRIVER && obj.AI_DRIVER === 'gemini')
|
|
@IsString()
|
|
@IsNotEmpty()
|
|
GEMINI_API_KEY: string;
|
|
|
|
@ValidateIf((obj) => obj.AI_DRIVER && obj.AI_DRIVER === 'ollama')
|
|
@IsUrl({ protocols: ['http', 'https'], require_tld: false })
|
|
OLLAMA_API_URL: string;
|
|
|
|
@IsOptional()
|
|
@IsIn(['postgres', 'clickhouse'])
|
|
@IsString()
|
|
EVENT_STORE_DRIVER: string;
|
|
|
|
@ValidateIf((obj) => obj.EVENT_STORE_DRIVER === 'clickhouse')
|
|
@IsNotEmpty()
|
|
@IsUrl(
|
|
{ protocols: ['http', 'https'], require_tld: false },
|
|
{
|
|
message:
|
|
'CLICKHOUSE_URL must be a valid URL e.g http://user:password@localhost:8123/docmost',
|
|
},
|
|
)
|
|
CLICKHOUSE_URL: string;
|
|
|
|
// --- git-sync (plan §7.2) — all OPTIONAL. The master switch defaults off; a
|
|
// required-if-enabled service user id is validated only when sync is on. ---
|
|
|
|
@IsOptional()
|
|
@IsIn(['true', 'false'])
|
|
@IsString()
|
|
GIT_SYNC_ENABLED: string;
|
|
|
|
@IsOptional()
|
|
@IsString()
|
|
GIT_SYNC_DATA_DIR: string;
|
|
|
|
@IsOptional()
|
|
@IsString()
|
|
GIT_SYNC_REMOTE_TEMPLATE: string;
|
|
|
|
@IsOptional()
|
|
@IsString()
|
|
GIT_SYNC_POLL_INTERVAL_MS: string;
|
|
|
|
@IsOptional()
|
|
@IsString()
|
|
GIT_SYNC_DEBOUNCE_MS: string;
|
|
|
|
// Defense-in-depth absolute cap on soft-deletes per push cycle (default 5): a
|
|
// non-convergent / phantom-absence cycle can never trash more than this many
|
|
// pages without an explicit override. Optional int (validated as a string env).
|
|
@IsOptional()
|
|
@IsString()
|
|
GIT_SYNC_MAX_DELETES_PER_CYCLE: string;
|
|
|
|
// Required when git-sync is enabled: the service user create/move/rename/delete
|
|
// are attributed to (plan §7.2). Optional otherwise.
|
|
@ValidateIf((obj) => obj.GIT_SYNC_ENABLED === 'true')
|
|
@IsNotEmpty()
|
|
@IsString()
|
|
GIT_SYNC_SERVICE_USER_ID: string;
|
|
|
|
@IsOptional()
|
|
@IsString()
|
|
GIT_SYNC_SSH_KEY_PATH: string;
|
|
}
|
|
|
|
export function validate(config: Record<string, any>) {
|
|
const validatedConfig = plainToInstance(EnvironmentVariables, config);
|
|
|
|
const errors = validateSync(validatedConfig);
|
|
|
|
if (errors.length > 0) {
|
|
console.error(
|
|
'The Environment variables has failed the following validations:',
|
|
);
|
|
|
|
errors.map((error) => {
|
|
console.error(JSON.stringify(error.constraints));
|
|
});
|
|
|
|
console.error(
|
|
'Please fix the environment variables and try again. Exiting program...',
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
return validatedConfig;
|
|
}
|