Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/HNU-himematsu/HNU-TimeLetter/llms.txt

Use this file to discover all available pages before exploring further.

The sync system is the operational backbone of HNU-TimeLetter. It provides a reliable, observable pipeline for pulling content from Feishu Bitable, processing image attachments through Aliyun OSS, and materialising the JSON artifacts that the frontend consumes at runtime. The architecture is intentionally self-contained — no external message brokers, no distributed coordination — designed for a single long-lived Node.js process managed by PM2 on a self-hosted server.
The sync system requires a single-instance, always-on Node.js process. It is incompatible with Serverless / edge runtimes and containerised multi-replica environments. If the deployment model changes to multi-instance or blue-green, the file-based lock and job store must be migrated to an external distributed store.

Three-Layer Architecture

┌─────────────────────────────────────────────────────────────┐
│  TRIGGER LAYER                                              │
│  CLI · Admin API · Scheduler · Webhook                      │
└──────────────────────────┬──────────────────────────────────┘
                           │ createSyncJob / runSyncJob
┌──────────────────────────▼──────────────────────────────────┐
│  ORCHESTRATOR LAYER                                         │
│  SyncOrchestrator — job lifecycle, file lock, persistence   │
└──────────────────────────┬──────────────────────────────────┘
                           │ execute TableSyncModule[]
┌──────────────────────────▼──────────────────────────────────┐
│  MODULE LAYER                                               │
│  locations · characters · stories                           │
│  creation_headers · creation_board · contributors           │
└─────────────────────────────────────────────────────────────┘

Layer 1 — Trigger Layer

All four trigger paths call the same orchestrator entry point, ensuring consistent job lifecycle handling regardless of how a sync is initiated.
1

CLI — npm run sync

The simplest trigger. Run from a terminal or a CI/CD pipeline step.
npm run sync
# expands to: tsx src/scripts/sync-feishu.ts
The script calls runSyncJob directly, streams progress to stdout, and exits with code 0 on success or 1 on failure.
// src/scripts/sync-feishu.ts
await runSyncJob({
  kind: 'sync-data',
  tables: ['locations', 'stories', 'creation_board'],
  triggeredBy: 'cli',
});
2

Admin API — POST /api/admin/sync/jobs

Used by the /admin back-office UI for manual one-click syncs. The request body selects which tables to run and the dependency mode.
// POST /api/admin/sync/jobs
await createSyncJob({
  kind: 'sync-data',
  tables: ['locations', 'stories'],
  dependencyMode: 'read_local',
  triggeredBy: 'admin-ui',
});
Returns the new job record immediately with status: "queued". The job executes asynchronously; the UI polls GET /api/admin/sync/jobs/:jobId for updates.
3

Scheduler — node-schedule

Initialised at process startup inside src/lib/admin/. Reads the cron expression and defaultTables list from runtime/admin/sync-config.json.
// src/lib/admin/ (scheduler initialisation)
await createSyncJob({
  kind: 'sync-data',
  tables: config.defaultTables,
  triggeredBy: 'scheduler',
});
Scheduled jobs are subject to the same concurrency lock as manual jobs — if a previous job is still running when the cron fires, the new job is rejected with a 409 Conflict status.
4

Webhook — POST /api/admin/sync/webhook

Allows external systems (e.g. Feishu automation flows, GitHub Actions) to trigger a sync over HTTP. Requests must include the SYNC_WEBHOOK_SECRET in the Authorization header.
curl -X POST https://your-server/api/admin/sync/webhook \
  -H "Authorization: Bearer $SYNC_WEBHOOK_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"tables": ["creation_board"]}'

Layer 2 — Orchestrator Layer

SyncOrchestrator in src/lib/sync/orchestrator.ts owns the entire job lifecycle: creation, locking, step execution, persistence, and cleanup.

Job Lifecycle

queued ──► running ──► success
                  ──► partial_success
                  ──► failed
                  ──► canceled
StatusDescription
queuedJob record created; waiting to acquire the file lock
runningLock acquired; table modules executing sequentially
successAll steps completed without errors
partial_successAt least one step succeeded; some steps failed but continueOnTableError was true
failedUnrecoverable error, or all steps failed
canceledJob was manually canceled before execution completed

File-Based Lock

A single lock file at runtime/admin/sync-lock.json prevents concurrent jobs. Its structure:
{
  "jobId": "sync-1746028800000-ab12",
  "pid": 12345,
  "createdAt": "2026-04-30T10:00:00.000Z",
  "expiresAt": "2026-04-30T10:30:00.000Z"
}
BehaviourDetail
New job vs. running jobReturns 409 Conflict with currentJobId in the response body
Lock timeoutSYNC_LOCK_TIMEOUT_MS — default 30 minutes (1800000 ms)
Stale lock recoveryExpired lock: old job is marked failed, lock is released, new job proceeds

Job Persistence

Each job record is written to runtime/admin/sync-jobs/<jobId>.json. The store retains the most recent 100 jobs; older files are purged automatically. Job records are read by the admin UI to render the job history table.

Layer 3 — Module Layer

Each Feishu table has a dedicated TableSyncModule. Modules are registered in src/lib/sync/registry.ts and executed in dependency order by the orchestrator.

SyncTableKey Registry

// src/lib/sync/registry.ts
export const tableRegistry = {
  locations:        locationsModule,
  characters:       charactersModule,
  stories:          storiesModule,
  creation_headers: creationHeadersModule,
  creation_board:   creationBoardModule,
  contributors:     contributorsModule,
} satisfies Record<SyncTableKey, TableSyncModule>;
SyncTableKey is derived from SyncTableOutputMap — it is never maintained as a separate union type. To add a new table, add a key to SyncTableOutputMap in src/lib/sync/types.ts; SyncTableKey updates automatically.

Table Registry Reference

SyncTableKeyFeishu sourceOutput artifactDependencies
locations地点表src/config/locations.jsonNone
characters角色表 (with avatar OSS back-fill)src/data/characters.jsonNone
stories展示表src/data/content.json (stories nested in LocationPoint[])locations, characters
creation_headers便签头表 (card metadata)src/data/creation-board-headers.jsonNone
creation_board揭示板 / 收集结果 viewsrc/data/creation-board.jsoncreation_headers
contributors鸣谢名单src/data/contributors.jsonNone

Step Status Values

Each module execution maps to one SyncJobStep. The step status reflects the outcome of that single table’s sync.
StatusDescription
pendingNot yet started
runningModule actively fetching records and processing assets
successAll records processed without errors
success_with_warningsPartial record failures; output artifact was written with available data
skippedSkipped because a required dependency was missing, or the job was aborted before this step
failedFatal error; output artifact was not written

Dependency Mode

When a table depends on another (e.g. stories requires locations and characters), the orchestrator uses the dependencyMode to decide how to satisfy those dependencies.
ModeBehaviourWhen to use
read_local(Default) Use the existing on-disk artifact for dependencies; only run the selected tablesLightweight refresh of one table when dependencies are already up to date
run_dependenciesAutomatically prepend dependency tables to the execution planFull update where the dependency chain may have changed
strictFail immediately if a dependency artifact is not present on diskValidation environments where stale data is unacceptable

Core Type Definitions

// src/lib/sync/types.ts

export type SyncJobKind = 'sync-data';

export type SyncTableKey = keyof SyncTableOutputMap;

export interface SyncTableOutputMap {
  locations:        LocationCoords;
  characters:       Character[];
  stories:          LocationPoint[];
  creation_headers: CardHeaderInfo[];
  creation_board:   CreationIdea[];
  contributors:     Contributor[];
}

export type DependencyMode = 'read_local' | 'run_dependencies' | 'strict';

export interface SyncJobRecord {
  jobId: string;
  kind: SyncJobKind;
  status: 'queued' | 'running' | 'success' | 'partial_success' | 'failed' | 'canceled';
  tables: SyncTableKey[];
  effectiveTables: SyncTableKey[];   // tables after dependency resolution
  dependencyMode: DependencyMode;
  includeAssets: boolean;            // whether to run the OSS image pipeline
  continueOnTableError: boolean;     // whether to keep going after a step failure
  triggeredBy: 'admin-ui' | 'scheduler' | 'cli' | 'webhook';
  note?: string;
  createdAt: string;
  startedAt?: string;
  syncedAt?: string;
  finishedAt?: string;
  durationMs?: number;
  steps: SyncJobStep[];
  summary?: Record<string, unknown>;
  warnings: string[];
  errors: string[];
}

export interface SyncJobStep {
  step: string;     // SyncTableKey value
  status: 'pending' | 'running' | 'success' | 'success_with_warnings' | 'skipped' | 'failed';
  startedAt?: string;
  finishedAt?: string;
  summary?: {
    totalRecords?: number;
    successRecords?: number;
    skippedRecords?: number;
    failedRecords?: number;
    filesWritten?: string[];
    [key: string]: unknown;
  };
  warnings?: string[];
  errors?: string[];
}

OSS Image Pipeline

Image assets in Feishu are private attachments. The sync system downloads each attachment and re-hosts it on Aliyun OSS so the frontend can render them without Feishu credentials.
1

Detect attachment

The table module reads the attachment field (e.g. 角色头像, 大图, 请上传你的图片) from each Feishu record. If the corresponding OSS URL field is already populated, this record is skipped.
2

Download from Feishu

src/lib/sync/clients/feishu-drive-client.ts fetches the raw binary using the Feishu Drive API with the app’s OAuth token. The binary is held in memory (no temp files).
3

Compute MD5 for deduplication

src/lib/sync/shared/asset-processor.ts computes an MD5 hash of the binary content. The OSS object key is derived from this hash, so identical images are never uploaded twice.
4

Upload to Aliyun OSS

src/lib/sync/clients/oss-client.ts uploads the binary to the configured bucket. The resulting public URL follows the pattern https://<bucket>.<region>.aliyuncs.com/<md5>.<ext>.
5

Back-fill URL to Feishu

The orchestrator calls the Feishu Bitable update API to write the OSS URL back into the source record (e.g. 头像OSS_URL, 大图OSS_URL). On the next sync run, the OSS URL field is already populated and the download step is skipped.
The back-fill write requires the Feishu app to have Bitable record edit permission. If this permission is missing, the pipeline will still generate the OSS URL and write the JSON artifact, but the URL will not be persisted in Feishu — meaning the download and upload will repeat on every sync run.

File Write Strategy

All artifact files are written atomically to prevent corrupt reads if the process crashes mid-write.
1. Serialize data to JSON string
2. Write to <target>.tmp
3. Validate the .tmp file is parseable JSON
4. fs.rename(<target>.tmp, <target>)  ← atomic on POSIX
Empty-result guard: If Feishu returns zero records for a table, the existing artifact on disk is not overwritten. A warning is recorded on the job step and execution continues. This prevents a Feishu API outage from erasing production data.

Runtime Directory Layout

runtime/                          # SYNC_RUNTIME_DIR (not committed)
└── admin/
    ├── sync-config.json          # Scheduler cron + defaultTables config
    ├── sync-lock.json            # Single-instance concurrency lock
    └── sync-jobs/
        └── <jobId>.json          # One file per job (max 100, auto-purged)

logs/                             # SYNC_LOG_DIR (not committed)
└── sync/
    └── <jobId>.log               # Per-job log file

src/data/                         # Build-time artifacts (committed)
├── content.json
├── characters.json
├── creation-board.json
├── creation-board-headers.json
└── contributors.json

src/config/                       # Build-time config (committed)
└── locations.json

Environment Variables

VariableDefaultDescription
SYNC_RUNTIME_DIRruntime/Root directory for job records and lock file
SYNC_LOG_DIRlogs/sync/Directory for per-job log files
SYNC_LOCK_TIMEOUT_MS1800000Lock expiry in milliseconds (30 minutes)
SYNC_WEBHOOK_SECRET(none)Secret token for authenticating webhook-triggered syncs
runtime/ and logs/ are listed in .gitignore and should never be committed. The src/data/ and src/config/ artifacts are committed — they serve as the canonical build-time data and as fallback content for environments where no sync has run.

Source Module Directory

src/lib/sync/
├── types.ts                      # All type definitions (SyncJobRecord, SyncTableKey, …)
├── orchestrator.ts               # Job creation, execution, status management
├── job-store.ts                  # File-based job record persistence
├── lock.ts                       # File lock acquire / release / expire
├── config.ts                     # Read / write sync-config.json
├── paths.ts                      # Runtime path resolution from env vars
├── logger.ts                     # Per-job structured log writer
├── registry.ts                   # tableRegistry — module registration
├── clients/
│   ├── feishu-auth-client.ts     # OAuth token management
│   ├── feishu-bitable-client.ts  # Bitable read + record update
│   ├── feishu-drive-client.ts    # Attachment download
│   └── oss-client.ts             # Aliyun OSS upload
├── shared/
│   ├── asset-processor.ts        # MD5 computation + OSS dedup logic
│   ├── field-reader.ts           # Feishu field extraction utilities
│   ├── text.ts                   # String normalisation helpers
│   ├── dates.ts                  # Timestamp parsing helpers
│   └── files.ts                  # Atomic write helpers
├── tables/
│   ├── locations.module.ts
│   ├── characters.module.ts
│   ├── stories.module.ts
│   ├── creation-headers.module.ts
│   ├── creation-board.module.ts
│   └── contributors.module.ts
└── writers/
    ├── json-writer.ts            # Shared atomic JSON writer
    ├── locations.writer.ts
    ├── characters.writer.ts
    ├── content.writer.ts
    ├── creation-board.writer.ts
    └── creation-board-headers.writer.ts

Adding a New Feishu Table

To register a new Feishu table in the sync system, follow these steps in order:
1

Extend SyncTableOutputMap

In src/lib/sync/types.ts, add your new key and its output type. SyncTableKey updates automatically.
export interface SyncTableOutputMap {
  // ... existing entries ...
  my_new_table: MyNewType[];
}
2

Define the domain type (if new)

If MyNewType is a new entity, define it in src/lib/types.ts and import it into src/lib/sync/types.ts.
3

Implement the table module

Create src/lib/sync/tables/my-new-table.module.ts implementing TableSyncModule<'my_new_table'>.
4

Implement the writer (optional)

If the output format requires custom serialisation, create src/lib/sync/writers/my-new-table.writer.ts. Otherwise use the shared json-writer.ts.
5

Register the module

Add the module to tableRegistry in src/lib/sync/registry.ts.
export const tableRegistry = {
  // ... existing entries ...
  my_new_table: myNewTableModule,
} satisfies Record<SyncTableKey, TableSyncModule>;
6

Update defaultTables (optional)

If the new table should sync automatically on the scheduler, add "my_new_table" to the defaultTables array in runtime/admin/sync-config.json.

Build docs developers (and LLMs) love