Răsfoiți Sursa

feat: add Parse REST VOC persistence

gangvy 1 lună în urmă
părinte
comite
f6f7cf3a12

+ 2 - 0
.env.example

@@ -1,4 +1,5 @@
 NODE_ENV=development
+STORAGE_DRIVER=parse_rest
 LOCAL_HOST=127.0.0.1
 LOCAL_PORT=4400
 LOCAL_WORKSPACE_ID=demashi
@@ -14,6 +15,7 @@ PARSE_APP_ID=
 PARSE_MASTER_KEY=
 PARSE_MAINTENANCE_KEY=
 PARSE_SERVER_URL=http://127.0.0.1:4400/parse
+PARSE_REST_TIMEOUT_MS=30000
 API_AUTH_MODE=parse
 LOCAL_AUTH_USER_ID=local-admin
 LOCAL_AUTH_USER_EMAIL=local-admin@localhost

+ 31 - 7
README.md

@@ -4,7 +4,7 @@ Independent backend template for the domestic ecommerce VOC product. The first c
 
 ## Current state
 
-Completed on 2026-07-23:
+Completed through 2026-07-24:
 
 - Independent Git repository and Node.js 22 / TypeScript service baseline.
 - Independent PostgreSQL schema with workspace, source, product, metric, relation, review, import, and sync-job tables.
@@ -25,8 +25,10 @@ Completed on 2026-07-23:
 - Configurable local workspace identity instead of a Demashi-only runtime branch.
 - Manual sync retry/cancel operations plus automatic recovery for stale processing jobs.
 - Owner-only owner changes, last-owner protection, and workspace-member action assignment validation.
+- Parse REST storage adapter for environments where only a Parse Server URL and application credentials are available.
+- Master-key-only `Voc*` class schemas, resumable bounded imports, direct count verification, and a Parse-backed sync worker.
 
-The current case import resolves to 2,817 operating products, 9,717 daily metrics, 40 relations, and 38 internal `relation_stub` records required to preserve foreign keys. Stub records are excluded from the frontend product list and are never presented as collected product facts.
+The current case import resolves to 2,817 operating products, 9,717 daily metrics, 40 relations, and 0 reviews. The PostgreSQL importer may create internal `relation_stub` rows to preserve foreign keys; the Parse REST model stores denormalized relation identities and therefore does not create those extra catalog products.
 
 The worker calls JD product-detail and review paths only through the company `/api/voc-e-commerce` gateway. It never sends a browser request to a supplier endpoint and never returns raw gateway errors or credentials.
 
@@ -38,6 +40,7 @@ Saas-voc frontend
       -> shared API and authorization contract
           -> local in-memory repository (development)
           -> dedicated PostgreSQL repository (production, voc schema)
+          -> external Parse REST repository (shared development or managed Parse)
       -> dedicated Parse application (production authentication)
       -> sync queue
           -> existing /api/voc-e-commerce gateway
@@ -58,7 +61,25 @@ The first worker run defaults to one review page. `JD_REVIEW_MAX_PAGES` can rais
 
 Use Node.js 22.13 or newer within the Node 22 release line. The repository intentionally pins Node 22 because Parse Server publishes explicit supported runtime ranges.
 
-Required configuration is listed in `.env.example`. Empty secrets are rejected before the HTTP server starts. Generate independent random values for `PARSE_MASTER_KEY` and `PARSE_MAINTENANCE_KEY`; do not reuse any value from `moshengqi-server` or `future-server`. `DATABASE_URL` is the least-privilege runtime connection. `MIGRATION_DATABASE_URL` is the schema-owner connection used only by `npm run migrate`; it falls back to `DATABASE_URL` for local development.
+Required configuration is listed in `.env.example`. Empty secrets are rejected before the HTTP server starts. `STORAGE_DRIVER=parse_rest` requires the external Parse URL, application id, and master key but no direct database connection. `STORAGE_DRIVER=postgres` additionally requires `DATABASE_URL`, a maintenance key, and optionally a separate `MIGRATION_DATABASE_URL`. Never commit live credentials.
+
+### Parse REST runtime
+
+Use this mode when PostgreSQL is not exposed and the project must connect through an existing Parse Server REST API:
+
+```powershell
+$env:STORAGE_DRIVER = 'parse_rest'
+$env:PARSE_SERVER_URL = 'https://parse.example.com/parse'
+$env:PARSE_APP_ID = '<application-id>'
+$env:PARSE_MASTER_KEY = '<process-only-master-key>'
+$env:API_AUTH_MODE = 'disabled' # isolated local development only
+$env:FMODE_API_KEY = '<process-only-company-gateway-key>'
+npm run bootstrap:parse-rest -- "E:\workspace\Saas-voc\src\assets\data\demashi-summary.json" demashi
+npm run verify:parse-rest -- demashi jd
+npm run dev
+```
+
+The bootstrap command creates or reconciles 14 isolated `Voc*` classes, seeds the workspace/member/source records, imports the normalized case in request-size-bounded batches, and is idempotent for the same source hash and verified counts. The verification command checks the schema set, class-level permissions, workspace/import readiness, exact object counts, and denial of app-id-only reads. See `docs/parse-rest-schema.md` for the class contract.
 
 ### Local frontend integration without a database
 
@@ -110,7 +131,7 @@ npm run import:dataset -- "E:\workspace\Saas-voc\src\assets\data\demashi-summary
 
 ### Authentication and first administrator
 
-Use `API_AUTH_MODE=parse` in production. API clients send either `X-Parse-Session-Token` or `Authorization: Bearer <session-token>`. The backend validates the session through its own `/parse/users/me` endpoint and never sends a Parse master or maintenance key to the browser.
+Use `API_AUTH_MODE=parse` in production. API clients send either `X-Parse-Session-Token` or `Authorization: Bearer <session-token>`. The backend validates the session through `PARSE_SERVER_URL/users/me` and never sends a Parse master or maintenance key to the browser.
 
 For the first production start, set `SAAS_BOOTSTRAP_ADMIN_USER_ID` to the Parse user object id and optionally set its email and display name. The service upserts that user as owner of `SAAS_DEFAULT_WORKSPACE_ID`. Clear the bootstrap variables after successful verification so a later restart cannot silently restore that account's owner role.
 
@@ -189,7 +210,7 @@ An empty database returns a valid empty dataset. It does not invent reviews, rat
 - Action assignees must be active members of the same workspace.
 - Member, sync, analysis, action, and alert writes append an audit entry.
 - Production deployments must use a pooled, least-privilege runtime role. Migration credentials should be separate from runtime credentials.
-- The HTTP server never runs DDL automatically; migrations are an explicit deployment step.
+- PostgreSQL DDL is always an explicit migration step. Parse REST mode reconciles only the isolated `Voc*` class schemas at startup through the schema REST API.
 
 ## Infrastructure handoff
 
@@ -211,18 +232,21 @@ When the dedicated database and server are available, no route or frontend contr
 ```powershell
 npm run build
 npm test
+npm run verify:parse-rest -- demashi jd
 npm audit --omit=dev
 ```
 
 Current result:
 
 - TypeScript build: passed.
-- Unit, adapter, worker recovery, local-demo, authentication, RBAC, cursor, workflow, audit, and HTTP contract tests: 29 passed on Node.js 22.13.0.
+- Unit, REST client, adapter, worker recovery, local-demo, authentication, RBAC, cursor, workflow, audit, and HTTP contract tests: 33 passed.
 - Production dependency audit: 0 critical, 0 high, 13 moderate.
 - Company gateway health: HTTP 200 on 2026-07-23.
 - Credentialed live JD product-detail request: passed through the company gateway for product `11266507445`; the adapter extracted the product id, title, and brand from the live double-`data` envelope.
 - Credentialed live JD review request: authenticated successfully and returned an empty first page with next-page metadata. A non-empty review sample is still required before review evidence can be accepted.
-- The live credential was process-only: it was not written to an environment file, source file, fixture, log, or Git history. No supplier endpoint was contacted directly.
+- Live credentials are absent from the repository, fixtures, logs, and Git history. No supplier endpoint is contacted directly.
+- Parse REST development verification: 14/14 `Voc*` schemas present, master-key-only access confirmed, 2,817 products, 9,717 metrics, 40 relations, and 0 reviews.
+- End-to-end local API mode: `/health`, SaaS context, full snapshot, frontend proxy, desktop navigation, and 390x844 responsive rendering passed against the Parse REST store.
 - PostgreSQL integration: pending a newly provisioned database. The local Docker CLI is installed but its engine was unavailable on 2026-07-23; no existing database was contacted.
 
 See `TASKS.md` for the implementation sequence and acceptance boundary.

+ 7 - 5
TASKS.md

@@ -1,6 +1,6 @@
 # Backend implementation tasks
 
-Status date: 2026-07-23
+Status date: 2026-07-24
 
 ## Phase 1 - isolated backend baseline
 
@@ -26,15 +26,17 @@ Status date: 2026-07-23
 - [x] Protect owner membership and validate action assignees inside the workspace boundary.
 - [ ] Run migrations against a newly provisioned PostgreSQL database.
 - [x] Smoke-test the local HTTP service under the supported Node 22 runtime.
+- [x] Add a Parse REST storage driver that preserves the existing frontend/backend API contract.
+- [x] Add isolated master-key-only `Voc*` schemas and remote schema reconciliation.
 
 ## Phase 2 - case-data persistence
 
 - [x] Add a batch importer for the normalized Demashi dataset.
-- [ ] UPSERT 2,817 operating products, 38 relation stubs, and 9,717 daily metric rows in bounded batches.
-- [ ] UPSERT 40 competitor relations with deterministic relation keys.
-- [ ] Verify snapshot totals against `demashi-summary.json`.
+- [x] Import 2,817 operating products and 9,717 daily metric rows through bounded Parse REST batches.
+- [x] Import 40 competitor relations with deterministic relation keys.
+- [x] Verify Parse counts and snapshot totals against `demashi-summary.json`.
 - [x] Switch `DomesticDatasetService` between static case mode and backend API mode.
-- [ ] Run the existing 29-route desktop/mobile audit against API mode.
+- [x] Run desktop/mobile browser smoke tests against Parse REST API mode.
 
 ## Phase 3 - real JD source contract
 

+ 33 - 0
docs/parse-rest-schema.md

@@ -0,0 +1,33 @@
+# Parse REST VOC schema
+
+The Parse REST storage driver uses isolated `Voc*` classes so it can coexist with unrelated applications in a shared Parse development environment. Every class is configured for master-key-only find, get, count, create, update, delete, and field changes. Browser clients never receive the master key and access data through `saas-voc-server`.
+
+| Class | Purpose | Stable identity |
+| --- | --- | --- |
+| `VocWorkspace` | Tenant/case workspace | `publicId` |
+| `VocWorkspaceMember` | Workspace role and status | `workspaceId + userId` in `naturalKey` |
+| `VocSourceConnection` | Company relay configuration metadata | workspace/platform/kind in `naturalKey` |
+| `VocImportBatch` | Import provenance, totals, daily aggregates, and quality report | `publicId` |
+| `VocProduct` | Domestic product catalog plus derived summary/trend | workspace/platform/product in `naturalKey` |
+| `VocDailyMetric` | Canonical daily operating metrics | workspace/platform/product/date/source in `naturalKey` |
+| `VocProductRelation` | Denormalized own-product to competitor mapping | workspace/platform/relation in `naturalKey` |
+| `VocReview` | Review evidence and source payload | workspace/platform/review in `naturalKey` |
+| `VocSyncJob` | Idempotent collection queue record | `publicId` and `idempotencyKey` |
+| `VocSyncJobEvent` | Sync progress and failure events | `publicId` |
+| `VocAnalysisRun` | Truthful pending/completed analysis lifecycle | `publicId` |
+| `VocActionItem` | Operational action workflow | `publicId` |
+| `VocAlert` | Risk/data-quality alert workflow | `publicId` |
+| `VocAuditLog` | Workspace-scoped write audit trail | `publicId` |
+
+Parse does not provide the same relational constraints as the PostgreSQL `voc` schema. The application therefore validates enums and permissions with Zod/RBAC, uses deterministic natural keys for idempotency, denormalizes relation identities, and restricts the current Parse worker to a single process because claim-by-update is not a SQL row lock.
+
+The current Demashi acceptance totals are:
+
+```text
+VocProduct          2817
+VocDailyMetric      9717
+VocProductRelation    40
+VocReview              0
+```
+
+Run `npm run verify:parse-rest -- demashi jd` after schema changes or imports. A zero review count is intentional until the company relay returns verified review evidence.

+ 2 - 0
package.json

@@ -14,6 +14,8 @@
     "start": "node dist/src/server.js",
     "start:local": "tsx src/local-server.ts",
     "migrate": "tsx scripts/migrate.ts",
+    "bootstrap:parse-rest": "tsx scripts/bootstrap-parse-rest.ts",
+    "verify:parse-rest": "tsx scripts/verify-parse-rest.ts",
     "import:dataset": "tsx scripts/import-dataset.ts",
     "test": "tsx --test test/**/*.test.ts",
     "test:coverage": "tsx --test --experimental-test-coverage test/**/*.test.ts",

+ 32 - 0
scripts/bootstrap-parse-rest.ts

@@ -0,0 +1,32 @@
+import 'dotenv/config';
+import { readFile } from 'node:fs/promises';
+import { resolve } from 'node:path';
+import { loadConfig } from '../src/config/env.js';
+import { ParseRestClient } from '../src/db/parse-rest.client.js';
+import { ensureVocParseSchemas } from '../src/db/parse-rest.schema.js';
+import { ParseRestDatasetImportService } from '../src/modules/domestic-voc/services/parse-rest-dataset-import.service.js';
+import type { ImportDataset } from '../src/modules/domestic-voc/services/dataset-import.service.js';
+
+const inputPath = process.argv[2];
+if (!inputPath) throw new Error('Usage: npm run bootstrap:parse-rest -- <dataset.json> [workspaceId]');
+
+const config = loadConfig();
+if (config.storageDriver !== 'parse_rest') throw new Error('STORAGE_DRIVER must be parse_rest');
+const workspaceId = process.argv[3] || config.auth.defaultWorkspaceId;
+const dataset = JSON.parse(await readFile(resolve(inputPath), 'utf8')) as ImportDataset;
+const client = new ParseRestClient({
+  serverUrl: config.parse.serverUrl,
+  appId: config.parse.appId,
+  masterKey: config.parse.masterKey,
+  timeoutMs: config.parse.timeoutMs,
+});
+
+const schema = await ensureVocParseSchemas(client);
+const importer = new ParseRestDatasetImportService(client);
+const imported = await importer.import(dataset, workspaceId, {
+  userId: config.auth.localUserId,
+  email: config.auth.localUserEmail,
+  displayName: config.auth.localUserName,
+});
+
+console.log(JSON.stringify({ schema, imported }, null, 2));

+ 60 - 0
scripts/verify-parse-rest.ts

@@ -0,0 +1,60 @@
+import 'dotenv/config';
+import { loadConfig } from '../src/config/env.js';
+import { ParseRestClient } from '../src/db/parse-rest.client.js';
+import { requiredVocClassNames, VOC_PARSE_CLASSES } from '../src/db/parse-rest.schema.js';
+
+const config = loadConfig();
+if (config.storageDriver !== 'parse_rest') throw new Error('STORAGE_DRIVER must be parse_rest');
+const workspaceId = process.argv[2] || config.auth.defaultWorkspaceId;
+const platform = process.argv[3] || 'jd';
+const client = new ParseRestClient({
+  serverUrl: config.parse.serverUrl,
+  appId: config.parse.appId,
+  masterKey: config.parse.masterKey,
+  timeoutMs: config.parse.timeoutMs,
+});
+
+const schemaResponse = await client.request<{
+  results: Array<{
+    className: string;
+    classLevelPermissions?: Record<string, Record<string, unknown>>;
+  }>;
+}>('/schemas');
+const vocSchemas = schemaResponse.results.filter((schema) => requiredVocClassNames.includes(schema.className));
+const operations = ['find', 'get', 'count', 'create', 'update', 'delete', 'addField'];
+const masterOnly = vocSchemas.every((schema) => operations.every((operation) => (
+  Object.keys(schema.classLevelPermissions?.[operation] ?? {}).length === 0
+)));
+
+const where = { workspaceId, platform };
+const [products, metrics, relations, reviews, workspace, completedImport] = await Promise.all([
+  client.count(VOC_PARSE_CLASSES.product, where),
+  client.count(VOC_PARSE_CLASSES.dailyMetric, where),
+  client.count(VOC_PARSE_CLASSES.productRelation, where),
+  client.count(VOC_PARSE_CLASSES.review, where),
+  client.findOne(VOC_PARSE_CLASSES.workspace, { publicId: workspaceId, status: 'active' }),
+  client.findOne(VOC_PARSE_CLASSES.importBatch, { workspaceId, platform, status: 'completed' }),
+]);
+
+const publicResponse = await fetch(
+  `${config.parse.serverUrl.replace(/\/+$/, '')}/classes/${VOC_PARSE_CLASSES.workspace}?limit=1`,
+  {
+    headers: { 'X-Parse-Application-Id': config.parse.appId },
+    signal: AbortSignal.timeout(config.parse.timeoutMs),
+  },
+);
+const publicPayload = await publicResponse.json().catch(() => ({})) as { code?: number };
+
+console.log(JSON.stringify({
+  schemas: {
+    expected: requiredVocClassNames.length,
+    actual: vocSchemas.length,
+    masterOnly,
+  },
+  workspaceReady: Boolean(workspace),
+  completedImport: Boolean(completedImport),
+  counts: { products, metrics, relations, reviews },
+  publicReadBlocked: !publicResponse.ok,
+  publicReadStatus: publicResponse.status,
+  publicReadCode: publicPayload.code ?? null,
+}, null, 2));

+ 33 - 6
src/app.ts

@@ -4,8 +4,8 @@ import type { Pool } from 'pg';
 import { ZodError } from 'zod';
 import type { AppConfig } from './config/env.js';
 import { ApiError } from './http/api-error.js';
-import { createDomesticVocRouter } from './modules/domestic-voc/routes.js';
-import { SyncJobRepository } from './modules/domestic-voc/repositories/sync-job.repository.js';
+import { createDomesticVocRouter, type DomesticSnapshotProvider } from './modules/domestic-voc/routes.js';
+import { SyncJobRepository, type SyncJobStore } from './modules/domestic-voc/repositories/sync-job.repository.js';
 import { SnapshotService } from './modules/domestic-voc/services/snapshot.service.js';
 import { SyncService } from './modules/domestic-voc/services/sync.service.js';
 import { createAuthenticationMiddleware, createAuthenticator, WorkspaceAccessService } from './modules/saas-platform/auth.js';
@@ -15,9 +15,12 @@ import { createSaasPlatformRouter } from './modules/saas-platform/routes.js';
 
 export function createApp(input: {
   config: AppConfig;
-  pool: Pool;
+  pool?: Pool;
   parseApp?: RequestHandler;
   platformRepository?: PlatformRepository;
+  jobs?: SyncJobStore;
+  snapshot?: DomesticSnapshotProvider;
+  healthCheck?: () => Promise<{ ready: boolean; missingObjects: string[] }>;
 }) {
   const app = express();
   app.disable('x-powered-by');
@@ -32,6 +35,29 @@ export function createApp(input: {
 
   app.get('/health', async (_request, response) => {
     try {
+      if (input.healthCheck) {
+        const readiness = await input.healthCheck();
+        if (!readiness.ready) {
+          response.status(503).json({
+            service: 'saas-voc-server',
+            status: 'unavailable',
+            database: readiness.missingObjects.length ? 'migration_required' : 'unavailable',
+            missingTables: readiness.missingObjects,
+            storage: input.config.storageDriver,
+            timestamp: new Date().toISOString(),
+          });
+          return;
+        }
+        response.json({
+          service: 'saas-voc-server',
+          status: 'ok',
+          database: 'ready',
+          storage: input.config.storageDriver,
+          timestamp: new Date().toISOString(),
+        });
+        return;
+      }
+      if (!input.pool) throw new Error('Database pool is not configured');
       const result = await input.pool.query<Record<string, string | null>>(`
         SELECT
           to_regclass('voc.workspace')::text AS workspace,
@@ -76,13 +102,14 @@ export function createApp(input: {
 
   if (input.parseApp) app.use('/parse', input.parseApp);
 
-  const platform = input.platformRepository ?? new PostgresPlatformRepository(input.pool);
+  if (!input.platformRepository && !input.pool) throw new Error('Platform repository is not configured');
+  const platform = input.platformRepository ?? new PostgresPlatformRepository(input.pool!);
   const access = new WorkspaceAccessService(platform);
   app.use('/api', createAuthenticationMiddleware(createAuthenticator(input.config)));
 
-  const jobs = new SyncJobRepository(input.pool);
+  const jobs = input.jobs ?? new SyncJobRepository(input.pool!);
   const sync = new SyncService(jobs);
-  const snapshot = new SnapshotService(input.pool);
+  const snapshot = input.snapshot ?? new SnapshotService(input.pool!);
   app.use('/api/domestic-voc', createDomesticVocRouter({
     jobs,
     sync,

+ 25 - 7
src/config/env.ts

@@ -7,17 +7,19 @@ const optionalNonEmptyString = z.preprocess(
 
 const environmentSchema = z.object({
   NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
+  STORAGE_DRIVER: z.enum(['postgres', 'parse_rest']).default('postgres'),
   HOST: z.string().min(1).default('127.0.0.1'),
   PORT: z.coerce.number().int().min(1).max(65535).default(4400),
-  DATABASE_URL: z.string().min(1, 'DATABASE_URL is required'),
+  DATABASE_URL: optionalNonEmptyString,
   MIGRATION_DATABASE_URL: optionalNonEmptyString,
   DATABASE_POOL_MAX: z.coerce.number().int().min(1).max(50).default(10),
   DATABASE_IDLE_TIMEOUT_MS: z.coerce.number().int().min(1000).default(30_000),
   DATABASE_STATEMENT_TIMEOUT_MS: z.coerce.number().int().min(1000).default(30_000),
-  PARSE_APP_ID: z.string().min(8, 'PARSE_APP_ID must contain at least 8 characters'),
-  PARSE_MASTER_KEY: z.string().min(32, 'PARSE_MASTER_KEY must contain at least 32 characters'),
-  PARSE_MAINTENANCE_KEY: z.string().min(32, 'PARSE_MAINTENANCE_KEY must contain at least 32 characters'),
+  PARSE_APP_ID: z.string().min(1, 'PARSE_APP_ID is required'),
+  PARSE_MASTER_KEY: z.string().min(1, 'PARSE_MASTER_KEY is required'),
+  PARSE_MAINTENANCE_KEY: optionalNonEmptyString,
   PARSE_SERVER_URL: z.url(),
+  PARSE_REST_TIMEOUT_MS: z.coerce.number().int().min(1000).max(120_000).default(30_000),
   API_AUTH_MODE: z.enum(['disabled', 'parse']).default('parse'),
   LOCAL_AUTH_USER_ID: z.string().min(1).default('local-admin'),
   LOCAL_AUTH_USER_EMAIL: z.string().default('local-admin@localhost'),
@@ -41,6 +43,7 @@ export type AppConfig = {
   nodeEnv: 'development' | 'test' | 'production';
   host: string;
   port: number;
+  storageDriver: 'postgres' | 'parse_rest';
   database: {
     url: string;
     migrationUrl: string;
@@ -53,6 +56,7 @@ export type AppConfig = {
     masterKey: string;
     maintenanceKey: string;
     serverUrl: string;
+    timeoutMs: number;
   };
   auth: {
     mode: 'disabled' | 'parse';
@@ -93,10 +97,22 @@ export function loadConfig(environment: NodeJS.ProcessEnv = process.env): AppCon
       const field = issue.path.join('.') || 'environment';
       return `${field}: ${issue.message}`;
     });
+    if ((environment.STORAGE_DRIVER ?? 'postgres') === 'postgres') {
+      if (!environment.DATABASE_URL?.trim()) issues.push('DATABASE_URL: required when STORAGE_DRIVER=postgres');
+      if (!environment.PARSE_MAINTENANCE_KEY?.trim()) {
+        issues.push('PARSE_MAINTENANCE_KEY: required when STORAGE_DRIVER=postgres');
+      }
+    }
     throw new EnvironmentConfigurationError(issues);
   }
 
   const value = result.data;
+  if (value.STORAGE_DRIVER === 'postgres' && !value.DATABASE_URL) {
+    throw new EnvironmentConfigurationError(['DATABASE_URL: required when STORAGE_DRIVER=postgres']);
+  }
+  if (value.STORAGE_DRIVER === 'postgres' && !value.PARSE_MAINTENANCE_KEY) {
+    throw new EnvironmentConfigurationError(['PARSE_MAINTENANCE_KEY: required when STORAGE_DRIVER=postgres']);
+  }
   if (value.NODE_ENV === 'production' && value.API_AUTH_MODE === 'disabled') {
     throw new EnvironmentConfigurationError([
       'API_AUTH_MODE: disabled authentication is not allowed in production',
@@ -106,9 +122,10 @@ export function loadConfig(environment: NodeJS.ProcessEnv = process.env): AppCon
     nodeEnv: value.NODE_ENV,
     host: value.HOST,
     port: value.PORT,
+    storageDriver: value.STORAGE_DRIVER,
     database: {
-      url: value.DATABASE_URL,
-      migrationUrl: value.MIGRATION_DATABASE_URL || value.DATABASE_URL,
+      url: value.DATABASE_URL ?? '',
+      migrationUrl: value.MIGRATION_DATABASE_URL || value.DATABASE_URL || '',
       poolMax: value.DATABASE_POOL_MAX,
       idleTimeoutMs: value.DATABASE_IDLE_TIMEOUT_MS,
       statementTimeoutMs: value.DATABASE_STATEMENT_TIMEOUT_MS,
@@ -116,8 +133,9 @@ export function loadConfig(environment: NodeJS.ProcessEnv = process.env): AppCon
     parse: {
       appId: value.PARSE_APP_ID,
       masterKey: value.PARSE_MASTER_KEY,
-      maintenanceKey: value.PARSE_MAINTENANCE_KEY,
+      maintenanceKey: value.PARSE_MAINTENANCE_KEY ?? '',
       serverUrl: value.PARSE_SERVER_URL,
+      timeoutMs: value.PARSE_REST_TIMEOUT_MS,
     },
     auth: {
       mode: value.API_AUTH_MODE,

+ 272 - 0
src/db/parse-rest.client.ts

@@ -0,0 +1,272 @@
+export type ParseFieldType =
+  | 'String'
+  | 'Number'
+  | 'Boolean'
+  | 'Date'
+  | 'Object'
+  | 'Array';
+
+export interface ParseFieldDefinition {
+  type: ParseFieldType;
+  required?: boolean;
+  defaultValue?: unknown;
+}
+
+export interface ParseClassSchema {
+  className: string;
+  fields: Record<string, ParseFieldDefinition>;
+  classLevelPermissions?: Record<string, Record<string, unknown>>;
+}
+
+export interface ParseObject {
+  objectId: string;
+  createdAt: string;
+  updatedAt: string;
+}
+
+export interface ParseQueryResult<T> {
+  results: Array<T & ParseObject>;
+  count?: number;
+}
+
+export interface ParseRequestOptions {
+  method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
+  body?: unknown;
+  master?: boolean;
+  sessionToken?: string;
+}
+
+export interface ParseFindOptions {
+  where?: Record<string, unknown>;
+  order?: string;
+  limit?: number;
+  skip?: number;
+  keys?: string[];
+  count?: boolean;
+}
+
+interface ParseErrorPayload {
+  code?: number;
+  error?: string;
+}
+
+export class ParseRestError extends Error {
+  constructor(
+    public readonly status: number,
+    public readonly code: number | null,
+    message: string,
+  ) {
+    super(message);
+    this.name = 'ParseRestError';
+  }
+}
+
+export function parseDate(value: Date | string): { __type: 'Date'; iso: string } {
+  return {
+    __type: 'Date',
+    iso: value instanceof Date ? value.toISOString() : new Date(value).toISOString(),
+  };
+}
+
+export function parseDateIso(value: unknown): string | null {
+  if (typeof value === 'string' && value) return new Date(value).toISOString();
+  if (value && typeof value === 'object' && '__type' in value && 'iso' in value) {
+    const iso = (value as { iso?: unknown }).iso;
+    return typeof iso === 'string' && iso ? new Date(iso).toISOString() : null;
+  }
+  return null;
+}
+
+export class ParseRestClient {
+  private readonly baseUrl: string;
+
+  constructor(
+    input: {
+      serverUrl: string;
+      appId: string;
+      masterKey: string;
+      timeoutMs?: number;
+    },
+    private readonly fetchImplementation: typeof fetch = globalThis.fetch,
+  ) {
+    this.baseUrl = input.serverUrl.replace(/\/+$/, '');
+    this.appId = input.appId;
+    this.masterKey = input.masterKey;
+    this.timeoutMs = input.timeoutMs ?? 30_000;
+  }
+
+  private readonly appId: string;
+  private readonly masterKey: string;
+  private readonly timeoutMs: number;
+
+  async request<T>(path: string, options: ParseRequestOptions = {}): Promise<T> {
+    const headers: Record<string, string> = {
+      Accept: 'application/json',
+      'X-Parse-Application-Id': this.appId,
+    };
+    if (options.master !== false) headers['X-Parse-Master-Key'] = this.masterKey;
+    if (options.sessionToken) headers['X-Parse-Session-Token'] = options.sessionToken;
+    if (options.body !== undefined) headers['Content-Type'] = 'application/json';
+
+    let response: Response;
+    try {
+      const init: RequestInit = {
+        method: options.method ?? 'GET',
+        headers,
+        signal: AbortSignal.timeout(this.timeoutMs),
+      };
+      if (options.body !== undefined) init.body = JSON.stringify(options.body);
+      response = await this.fetchImplementation(`${this.baseUrl}${path}`, init);
+    } catch (error) {
+      throw new ParseRestError(503, null, error instanceof Error ? error.message : 'Parse REST request failed');
+    }
+
+    const text = await response.text();
+    let payload: unknown = {};
+    if (text) {
+      try {
+        payload = JSON.parse(text);
+      } catch {
+        throw new ParseRestError(response.status, null, 'Parse REST returned a non-JSON response');
+      }
+    }
+    if (!response.ok) {
+      const error = payload as ParseErrorPayload;
+      throw new ParseRestError(
+        response.status,
+        typeof error.code === 'number' ? error.code : null,
+        typeof error.error === 'string' ? error.error : `Parse REST request failed with ${response.status}`,
+      );
+    }
+    return payload as T;
+  }
+
+  async health(): Promise<boolean> {
+    try {
+      const result = await this.request<{ status?: string }>('/health', { master: false });
+      return result.status === 'ok';
+    } catch {
+      return false;
+    }
+  }
+
+  async schemas(): Promise<ParseClassSchema[]> {
+    const response = await this.request<{ results: ParseClassSchema[] }>('/schemas');
+    return response.results ?? [];
+  }
+
+  async createSchema(schema: ParseClassSchema): Promise<void> {
+    await this.request(`/schemas/${encodeURIComponent(schema.className)}`, {
+      method: 'POST',
+      body: {
+        fields: schema.fields,
+        classLevelPermissions: masterOnlyClassPermissions(),
+      },
+    });
+  }
+
+  async addSchemaFields(className: string, fields: Record<string, ParseFieldDefinition>): Promise<void> {
+    if (!Object.keys(fields).length) return;
+    await this.request(`/schemas/${encodeURIComponent(className)}`, {
+      method: 'PUT',
+      body: { fields },
+    });
+  }
+
+  async setSchemaPermissions(className: string): Promise<void> {
+    await this.request(`/schemas/${encodeURIComponent(className)}`, {
+      method: 'PUT',
+      body: { classLevelPermissions: masterOnlyClassPermissions() },
+    });
+  }
+
+  async find<T>(className: string, options: ParseFindOptions = {}): Promise<ParseQueryResult<T>> {
+    const query = new URLSearchParams();
+    if (options.where) query.set('where', JSON.stringify(options.where));
+    if (options.order) query.set('order', options.order);
+    if (options.limit !== undefined) query.set('limit', String(options.limit));
+    if (options.skip !== undefined) query.set('skip', String(options.skip));
+    if (options.keys?.length) query.set('keys', options.keys.join(','));
+    if (options.count) query.set('count', '1');
+    const suffix = query.size ? `?${query.toString()}` : '';
+    return this.request<ParseQueryResult<T>>(`/classes/${encodeURIComponent(className)}${suffix}`);
+  }
+
+  async findOne<T>(className: string, where: Record<string, unknown>): Promise<(T & ParseObject) | null> {
+    const response = await this.find<T>(className, { where, limit: 1 });
+    return response.results[0] ?? null;
+  }
+
+  async findAll<T>(className: string, where: Record<string, unknown> = {}): Promise<Array<T & ParseObject>> {
+    const output: Array<T & ParseObject> = [];
+    let cursor = '';
+    for (;;) {
+      const cursorWhere = cursor ? { ...where, objectId: { $gt: cursor } } : where;
+      const response = await this.find<T>(className, {
+        where: cursorWhere,
+        order: 'objectId',
+        limit: 1_000,
+      });
+      output.push(...response.results);
+      if (response.results.length < 1_000) return output;
+      cursor = response.results.at(-1)!.objectId;
+    }
+  }
+
+  async count(className: string, where: Record<string, unknown> = {}): Promise<number> {
+    const response = await this.find(className, { where, limit: 0, count: true });
+    return response.count ?? 0;
+  }
+
+  async create<T extends Record<string, unknown>>(className: string, object: T): Promise<ParseObject> {
+    return this.request<ParseObject>(`/classes/${encodeURIComponent(className)}`, {
+      method: 'POST',
+      body: object,
+    });
+  }
+
+  async update<T extends Record<string, unknown>>(className: string, objectId: string, patch: T): Promise<{ updatedAt: string }> {
+    return this.request<{ updatedAt: string }>(
+      `/classes/${encodeURIComponent(className)}/${encodeURIComponent(objectId)}`,
+      { method: 'PUT', body: patch },
+    );
+  }
+
+  async delete(className: string, objectId: string): Promise<void> {
+    await this.request(`/classes/${encodeURIComponent(className)}/${encodeURIComponent(objectId)}`, {
+      method: 'DELETE',
+    });
+  }
+
+  async batch(requests: Array<{ method: 'POST' | 'PUT' | 'DELETE'; path: string; body?: unknown }>): Promise<void> {
+    if (requests.length > 50) throw new Error('Parse batch requests are limited to 50 operations');
+    const mountPath = new URL(this.baseUrl).pathname.replace(/\/+$/, '');
+    const payload = await this.request<Array<{ success?: unknown; error?: ParseErrorPayload }>>('/batch', {
+      method: 'POST',
+      body: {
+        requests: requests.map((request) => ({
+          method: request.method,
+          path: `${mountPath}${request.path.startsWith('/') ? request.path : `/${request.path}`}`,
+          ...(request.body === undefined ? {} : { body: request.body }),
+        })),
+      },
+    });
+    const failure = payload.find((result) => result.error);
+    if (failure?.error) {
+      throw new ParseRestError(400, failure.error.code ?? null, failure.error.error ?? 'Parse batch operation failed');
+    }
+  }
+}
+
+export function masterOnlyClassPermissions(): Record<string, Record<string, never> | { '*': never[] }> {
+  return {
+    find: {},
+    get: {},
+    count: {},
+    create: {},
+    update: {},
+    delete: {},
+    addField: {},
+    protectedFields: { '*': [] },
+  };
+}

+ 201 - 0
src/db/parse-rest.schema.ts

@@ -0,0 +1,201 @@
+import type { ParseClassSchema, ParseFieldDefinition } from './parse-rest.client.js';
+import { ParseRestClient } from './parse-rest.client.js';
+
+const string = (required = false): ParseFieldDefinition => ({ type: 'String', required });
+const number = (required = false): ParseFieldDefinition => ({ type: 'Number', required });
+const date = (required = false): ParseFieldDefinition => ({ type: 'Date', required });
+const object = (required = false): ParseFieldDefinition => ({ type: 'Object', required });
+const array = (required = false): ParseFieldDefinition => ({ type: 'Array', required });
+
+export const VOC_PARSE_CLASSES = {
+  workspace: 'VocWorkspace',
+  sourceConnection: 'VocSourceConnection',
+  importBatch: 'VocImportBatch',
+  product: 'VocProduct',
+  dailyMetric: 'VocDailyMetric',
+  productRelation: 'VocProductRelation',
+  review: 'VocReview',
+  syncJob: 'VocSyncJob',
+  syncJobEvent: 'VocSyncJobEvent',
+  workspaceMember: 'VocWorkspaceMember',
+  analysisRun: 'VocAnalysisRun',
+  actionItem: 'VocActionItem',
+  alert: 'VocAlert',
+  auditLog: 'VocAuditLog',
+} as const;
+
+export const VOC_PARSE_SCHEMAS: ParseClassSchema[] = [
+  {
+    className: VOC_PARSE_CLASSES.workspace,
+    fields: {
+      publicId: string(true), name: string(true), caseName: string(true), status: string(true),
+    },
+  },
+  {
+    className: VOC_PARSE_CLASSES.sourceConnection,
+    fields: {
+      naturalKey: string(true), workspaceId: string(true), platform: string(true),
+      connectionKind: string(true), status: string(true), metadata: object(true), lastCheckedAt: date(),
+    },
+  },
+  {
+    className: VOC_PARSE_CLASSES.importBatch,
+    fields: {
+      publicId: string(true), workspaceId: string(true), platform: string(true), sourceKind: string(true),
+      sourceFile: string(), sourceHash: string(), sheets: array(true), status: string(true),
+      totalRows: number(true), successRows: number(true), failedRows: number(true),
+      productRows: number(true), metricRows: number(true), relationRows: number(true), reviewRows: number(true),
+      startedAt: date(), completedAt: date(), sourceDateStart: string(), sourceDateEnd: string(),
+      dailyTotals: array(true), quality: object(true),
+    },
+  },
+  {
+    className: VOC_PARSE_CLASSES.product,
+    fields: {
+      naturalKey: string(true), workspaceId: string(true), platform: string(true), productId: string(true),
+      productKey: string(true), role: string(true), brand: string(), title: string(), model: string(),
+      category1: string(), category2: string(), category3: string(), source: string(true),
+      relationCount: number(true), summary: object(true), trend: array(true), rawPayload: object(),
+    },
+  },
+  {
+    className: VOC_PARSE_CLASSES.dailyMetric,
+    fields: {
+      naturalKey: string(true), workspaceId: string(true), platform: string(true), productId: string(true),
+      metricDate: string(true), source: string(true), gmv: number(true), soldUnits: number(true),
+      transactionOrders: number(true), transactionCustomers: number(true), impressions: number(true),
+      clicks: number(true), views: number(true), visitors: number(true), cartUnits: number(true),
+      orderAmount: number(true), orderUnits: number(true), orderCount: number(true),
+      refundAmount: number(true), refundUnits: number(true), refundOrders: number(true),
+    },
+  },
+  {
+    className: VOC_PARSE_CLASSES.productRelation,
+    fields: {
+      naturalKey: string(true), workspaceId: string(true), platform: string(true), relationKey: string(true),
+      ownProductId: string(true), ownProductKey: string(true), competitorProductId: string(true),
+      competitorProductKey: string(true), competitorBrand: string(), category: string(),
+      ownModel: string(), ownCategory1: string(), ownCategory2: string(), ownCategory3: string(),
+    },
+  },
+  {
+    className: VOC_PARSE_CLASSES.review,
+    fields: {
+      naturalKey: string(true), workspaceId: string(true), platform: string(true), productId: string(true),
+      sourceReviewId: string(), reviewKey: string(true), rating: number(), content: string(true),
+      reviewDate: date(), rawPayload: object(),
+    },
+  },
+  {
+    className: VOC_PARSE_CLASSES.syncJob,
+    fields: {
+      publicId: string(true), workspaceId: string(true), platform: string(true), idempotencyKey: string(true),
+      status: string(true), scopes: array(true), productIds: array(true), progress: number(true),
+      attempts: number(true), maxAttempts: number(true), workerId: string(), errorSummary: string(),
+      requestedAt: date(true), startedAt: date(), completedAt: date(),
+    },
+  },
+  {
+    className: VOC_PARSE_CLASSES.syncJobEvent,
+    fields: {
+      publicId: string(true), workspaceId: string(true), jobPublicId: string(true), level: string(true),
+      eventType: string(true), message: string(true), details: object(true),
+    },
+  },
+  {
+    className: VOC_PARSE_CLASSES.workspaceMember,
+    fields: {
+      naturalKey: string(true), workspaceId: string(true), userId: string(true), email: string(),
+      displayName: string(), role: string(true), status: string(true),
+    },
+  },
+  {
+    className: VOC_PARSE_CLASSES.analysisRun,
+    fields: {
+      publicId: string(true), workspaceId: string(true), analysisType: string(true), targetKind: string(true),
+      targetKey: string(true), status: string(true), input: object(true), result: object(),
+      evidenceCount: number(true), requestedBy: string(true), errorSummary: string(),
+      requestedAt: date(true), startedAt: date(), completedAt: date(),
+    },
+  },
+  {
+    className: VOC_PARSE_CLASSES.actionItem,
+    fields: {
+      publicId: string(true), workspaceId: string(true), sourceAnalysisId: string(), actionType: string(true),
+      title: string(true), description: string(), priority: string(true), status: string(true),
+      productKey: string(), assigneeUserId: string(), dueAt: date(), createdBy: string(true), completedAt: date(),
+    },
+  },
+  {
+    className: VOC_PARSE_CLASSES.alert,
+    fields: {
+      publicId: string(true), workspaceId: string(true), alertType: string(true), severity: string(true),
+      status: string(true), productKey: string(), title: string(true), summary: string(), evidence: array(true),
+      detectedAt: date(true), acknowledgedBy: string(), acknowledgedAt: date(), resolvedAt: date(),
+    },
+  },
+  {
+    className: VOC_PARSE_CLASSES.auditLog,
+    fields: {
+      publicId: string(true), workspaceId: string(true), actorUserId: string(true), action: string(true),
+      entityType: string(true), entityId: string(), metadata: object(true),
+    },
+  },
+];
+
+export interface ParseSchemaSyncResult {
+  created: string[];
+  updated: string[];
+  unchanged: string[];
+}
+
+export async function ensureVocParseSchemas(client: ParseRestClient): Promise<ParseSchemaSyncResult> {
+  const existing = new Map((await client.schemas()).map((schema) => [schema.className, schema]));
+  const result: ParseSchemaSyncResult = { created: [], updated: [], unchanged: [] };
+  for (const schema of VOC_PARSE_SCHEMAS) {
+    const current = existing.get(schema.className);
+    if (!current) {
+      await client.createSchema(schema);
+      result.created.push(schema.className);
+      continue;
+    }
+    const changed = Object.fromEntries(
+      Object.entries(schema.fields).filter(([name, definition]) => {
+        const field = current.fields?.[name];
+        return !field
+          || field.type !== definition.type
+          || Boolean(field.required) !== Boolean(definition.required);
+      }),
+    );
+    const fieldsChanged = Object.keys(changed).length > 0;
+    const permissionsChanged = !hasMasterOnlyPermissions(current.classLevelPermissions);
+    if (fieldsChanged) {
+      await client.addSchemaFields(schema.className, changed);
+    }
+    if (permissionsChanged) {
+      await client.setSchemaPermissions(schema.className);
+    }
+    if (fieldsChanged || permissionsChanged) {
+      result.updated.push(schema.className);
+    } else {
+      result.unchanged.push(schema.className);
+    }
+  }
+  return result;
+}
+
+export function hasMasterOnlyPermissions(
+  permissions: Record<string, Record<string, unknown>> | undefined,
+): boolean {
+  if (!permissions) return false;
+  return ['find', 'get', 'count', 'create', 'update', 'delete', 'addField'].every((operation) => (
+    Object.hasOwn(permissions, operation) && Object.keys(permissions[operation] ?? {}).length === 0
+  ));
+}
+
+export function isVocSchemaReady(classNames: Iterable<string>): boolean {
+  const available = new Set(classNames);
+  return VOC_PARSE_SCHEMAS.every((schema) => available.has(schema.className));
+}
+
+export const requiredVocClassNames = VOC_PARSE_SCHEMAS.map((schema) => schema.className);

+ 58 - 0
src/modules/domestic-voc/jobs/parse-rest-sync-worker.ts

@@ -0,0 +1,58 @@
+import type { ClaimedSyncJob } from './sync-worker.js';
+
+export interface ParseRestSyncQueue {
+  recoverStaleJobs(staleAfterMs: number): Promise<number>;
+  claimNextJob(workerId: string): Promise<ClaimedSyncJob | null>;
+}
+
+export function startParseRestSyncWorker(input: {
+  queue: ParseRestSyncQueue;
+  processor: { process(job: ClaimedSyncJob): Promise<void> };
+  pollMs: number;
+  staleAfterMs: number;
+  workerId?: string;
+}): { stop: () => Promise<void> } {
+  const workerId = input.workerId ?? `saas-voc-parse-rest-${process.pid}`;
+  let stopped = false;
+  let running = false;
+  let initialized = false;
+  let timer: NodeJS.Timeout | undefined;
+  let activeIteration: Promise<void> | null = null;
+
+  const schedule = () => {
+    if (stopped) return;
+    timer = setTimeout(() => void tick(), input.pollMs);
+    timer.unref();
+  };
+  const tick = async () => {
+    if (running || stopped) return;
+    running = true;
+    activeIteration = (async () => {
+      try {
+        if (!initialized) {
+          const recovered = await input.queue.recoverStaleJobs(input.staleAfterMs);
+          if (recovered) console.log(`[sync-worker] recovered ${recovered} stale Parse REST job(s)`);
+          initialized = true;
+        }
+        const job = await input.queue.claimNextJob(workerId);
+        if (job) await input.processor.process(job);
+      } catch {
+        console.error('[sync-worker] Parse REST worker iteration failed');
+      } finally {
+        running = false;
+        activeIteration = null;
+        schedule();
+      }
+    })();
+    await activeIteration;
+  };
+
+  void tick();
+  return {
+    async stop() {
+      stopped = true;
+      if (timer) clearTimeout(timer);
+      if (activeIteration) await activeIteration;
+    },
+  };
+}

+ 322 - 0
src/modules/domestic-voc/services/parse-rest-dataset-import.service.ts

@@ -0,0 +1,322 @@
+import { randomUUID } from 'node:crypto';
+import { ParseRestClient, parseDate } from '../../../db/parse-rest.client.js';
+import { VOC_PARSE_CLASSES } from '../../../db/parse-rest.schema.js';
+import type { DomesticMetricSummary } from '../../../types/domestic-dataset.js';
+import { extractImportRecords, type ImportDataset } from './dataset-import.service.js';
+
+export interface ParseDatasetImportResult {
+  batchId: string;
+  products: number;
+  metrics: number;
+  relations: number;
+  reviews: number;
+  skipped: boolean;
+}
+
+const EMPTY_SUMMARY: DomesticMetricSummary = {
+  gmv: 0,
+  soldUnits: 0,
+  transactionOrders: 0,
+  transactionCustomers: 0,
+  impressions: 0,
+  clicks: 0,
+  views: 0,
+  visitors: 0,
+  cartUnits: 0,
+  orderAmount: 0,
+  orderUnits: 0,
+  orderCount: 0,
+  refundAmount: 0,
+  refundUnits: 0,
+  refundOrders: 0,
+  conversionRate: 0,
+  clickThroughRate: 0,
+  averageUnitPrice: 0,
+  refundToGmvRate: 0,
+};
+
+const DATA_CLASSES = [
+  VOC_PARSE_CLASSES.dailyMetric,
+  VOC_PARSE_CLASSES.review,
+  VOC_PARSE_CLASSES.productRelation,
+  VOC_PARSE_CLASSES.product,
+  VOC_PARSE_CLASSES.importBatch,
+] as const;
+
+function naturalKey(...parts: string[]): string {
+  return parts.map((part) => encodeURIComponent(part)).join('|');
+}
+
+function chunks<T>(values: T[], size = 50): T[][] {
+  const output: T[][] = [];
+  for (let index = 0; index < values.length; index += size) output.push(values.slice(index, index + size));
+  return output;
+}
+
+function payloadChunks(values: Array<Record<string, unknown>>, maxBytes = 60_000): Array<Array<Record<string, unknown>>> {
+  const output: Array<Array<Record<string, unknown>>> = [];
+  let batch: Array<Record<string, unknown>> = [];
+  let bytes = 0;
+  for (const value of values) {
+    const valueBytes = Buffer.byteLength(JSON.stringify(value), 'utf8') + 160;
+    if (batch.length && (batch.length >= 50 || bytes + valueBytes > maxBytes)) {
+      output.push(batch);
+      batch = [];
+      bytes = 0;
+    }
+    batch.push(value);
+    bytes += valueBytes;
+  }
+  if (batch.length) output.push(batch);
+  return output;
+}
+
+export class ParseRestDatasetImportService {
+  constructor(private readonly client: ParseRestClient) {}
+
+  async import(
+    dataset: ImportDataset,
+    workspaceId = 'demashi',
+    principal: { userId: string; email: string; displayName: string } = {
+      userId: 'local-admin',
+      email: 'local-admin@localhost',
+      displayName: 'Local Admin',
+    },
+  ): Promise<ParseDatasetImportResult> {
+    const records = extractImportRecords(dataset);
+    const datasetProductById = new Map(dataset.products.map((product) => [product.productId, product]));
+    const importedProducts = records.products.filter((product) => datasetProductById.has(product.productId));
+    const existing = await this.client.findOne<{
+      publicId: string;
+      sourceHash?: string;
+      status: string;
+    }>(VOC_PARSE_CLASSES.importBatch, {
+      workspaceId,
+      platform: dataset.platform,
+      sourceHash: dataset.source.sourceHash,
+      status: 'completed',
+    });
+    if (existing) {
+      const counts = await this.counts(workspaceId, dataset.platform);
+      if (
+        counts.products === importedProducts.length
+        && counts.metrics === records.metrics.length
+        && counts.relations === records.relations.length
+        && counts.reviews === records.reviews.length
+      ) {
+        return { batchId: existing.publicId, ...counts, skipped: true };
+      }
+    }
+
+    await this.ensureWorkspace(workspaceId, dataset.caseName, principal);
+    await this.purgeDataset(workspaceId, dataset.platform);
+
+    const batchId = randomUUID();
+    const totalRows = importedProducts.length + records.metrics.length + records.relations.length + records.reviews.length;
+    const batch = await this.client.create(VOC_PARSE_CLASSES.importBatch, {
+      publicId: batchId,
+      workspaceId,
+      platform: dataset.platform,
+      sourceKind: 'normalized_dataset',
+      sourceFile: dataset.source.sourceFile,
+      sourceHash: dataset.source.sourceHash,
+      sheets: dataset.source.sheets ?? [],
+      status: 'processing',
+      totalRows,
+      successRows: 0,
+      failedRows: 0,
+      productRows: importedProducts.length,
+      metricRows: records.metrics.length,
+      relationRows: records.relations.length,
+      reviewRows: records.reviews.length,
+      startedAt: parseDate(new Date()),
+      sourceDateStart: dataset.source.dateRange.start,
+      sourceDateEnd: dataset.source.dateRange.end,
+      dailyTotals: dataset.dailyTotals,
+      quality: dataset.quality,
+    });
+
+    try {
+      const relationCount = new Map<string, number>();
+      for (const relation of records.relations) {
+        relationCount.set(relation.ownProductId, (relationCount.get(relation.ownProductId) ?? 0) + 1);
+      }
+      await this.createMany(VOC_PARSE_CLASSES.product, importedProducts.map((product) => {
+        const sourceProduct = datasetProductById.get(product.productId);
+        return {
+          naturalKey: naturalKey(workspaceId, product.platform, product.productId),
+          workspaceId,
+          platform: product.platform,
+          productId: product.productId,
+          productKey: product.productKey,
+          role: product.role,
+          brand: product.brand,
+          title: product.title,
+          model: product.model,
+          category1: product.category1,
+          category2: product.category2,
+          category3: product.category3,
+          source: product.source,
+          relationCount: relationCount.get(product.productId) ?? 0,
+          summary: sourceProduct?.summary ?? EMPTY_SUMMARY,
+          trend: sourceProduct?.trend ?? [],
+        };
+      }));
+
+      await this.createMany(VOC_PARSE_CLASSES.dailyMetric, records.metrics.map((metric) => ({
+        naturalKey: naturalKey(workspaceId, metric.platform, metric.productId, metric.date, metric.source),
+        workspaceId,
+        platform: metric.platform,
+        productId: metric.productId,
+        metricDate: metric.date,
+        source: metric.source,
+        gmv: metric.gmv ?? 0,
+        soldUnits: metric.soldUnits ?? 0,
+        transactionOrders: metric.transactionOrders ?? 0,
+        transactionCustomers: metric.transactionCustomers ?? 0,
+        impressions: metric.impressions ?? 0,
+        clicks: metric.clicks ?? 0,
+        views: metric.views ?? 0,
+        visitors: metric.visitors ?? 0,
+        cartUnits: metric.cartUnits ?? 0,
+        orderAmount: metric.orderAmount ?? 0,
+        orderUnits: metric.orderUnits ?? 0,
+        orderCount: metric.orderCount ?? 0,
+        refundAmount: metric.refundAmount ?? 0,
+        refundUnits: metric.refundUnits ?? 0,
+        refundOrders: metric.refundOrders ?? 0,
+      })));
+
+      const productById = new Map(importedProducts.map((product) => [product.productId, product]));
+      await this.createMany(VOC_PARSE_CLASSES.productRelation, records.relations.map((relation) => {
+        const own = productById.get(relation.ownProductId);
+        return {
+          naturalKey: naturalKey(workspaceId, dataset.platform, relation.relationKey),
+          workspaceId,
+          platform: dataset.platform,
+          relationKey: relation.relationKey,
+          ownProductId: relation.ownProductId,
+          ownProductKey: relation.ownProductKey,
+          competitorProductId: relation.competitorProductId,
+          competitorProductKey: relation.competitorProductKey,
+          competitorBrand: relation.competitorBrand,
+          category: relation.category,
+          ownModel: own?.model ?? '',
+          ownCategory1: own?.category1 ?? '',
+          ownCategory2: own?.category2 ?? '',
+          ownCategory3: own?.category3 ?? '',
+        };
+      }));
+
+      await this.createMany(VOC_PARSE_CLASSES.review, records.reviews.map((review) => ({
+        naturalKey: naturalKey(workspaceId, dataset.platform, review.productId, review.reviewId),
+        workspaceId,
+        platform: dataset.platform,
+        productId: review.productId,
+        sourceReviewId: review.reviewId,
+        reviewKey: review.reviewId,
+        rating: review.rating,
+        content: review.content,
+        ...(review.reviewDate ? { reviewDate: parseDate(review.reviewDate) } : {}),
+      })));
+
+      await this.client.update(VOC_PARSE_CLASSES.importBatch, batch.objectId, {
+        status: 'completed',
+        successRows: totalRows,
+        completedAt: parseDate(new Date()),
+      });
+    } catch (error) {
+      await this.client.update(VOC_PARSE_CLASSES.importBatch, batch.objectId, {
+        status: 'failed',
+        failedRows: totalRows,
+        completedAt: parseDate(new Date()),
+      }).catch(() => undefined);
+      throw error;
+    }
+
+    return {
+      batchId,
+      products: importedProducts.length,
+      metrics: records.metrics.length,
+      relations: records.relations.length,
+      reviews: records.reviews.length,
+      skipped: false,
+    };
+  }
+
+  async counts(workspaceId: string, platform: string): Promise<Omit<ParseDatasetImportResult, 'batchId' | 'skipped'>> {
+    const where = { workspaceId, platform };
+    const [products, metrics, relations, reviews] = await Promise.all([
+      this.client.count(VOC_PARSE_CLASSES.product, where),
+      this.client.count(VOC_PARSE_CLASSES.dailyMetric, where),
+      this.client.count(VOC_PARSE_CLASSES.productRelation, where),
+      this.client.count(VOC_PARSE_CLASSES.review, where),
+    ]);
+    return { products, metrics, relations, reviews };
+  }
+
+  private async ensureWorkspace(
+    workspaceId: string,
+    caseName: string,
+    principal: { userId: string; email: string; displayName: string },
+  ): Promise<void> {
+    const workspace = await this.client.findOne(VOC_PARSE_CLASSES.workspace, { publicId: workspaceId });
+    const workspaceBody = {
+      publicId: workspaceId,
+      name: `${caseName} JD VOC`,
+      caseName,
+      status: 'active',
+    };
+    if (workspace) await this.client.update(VOC_PARSE_CLASSES.workspace, workspace.objectId, workspaceBody);
+    else await this.client.create(VOC_PARSE_CLASSES.workspace, workspaceBody);
+
+    const memberKey = naturalKey(workspaceId, principal.userId);
+    const member = await this.client.findOne(VOC_PARSE_CLASSES.workspaceMember, { naturalKey: memberKey });
+    const memberBody = {
+      naturalKey: memberKey,
+      workspaceId,
+      userId: principal.userId,
+      email: principal.email,
+      displayName: principal.displayName,
+      role: 'owner',
+      status: 'active',
+    };
+    if (member) await this.client.update(VOC_PARSE_CLASSES.workspaceMember, member.objectId, memberBody);
+    else await this.client.create(VOC_PARSE_CLASSES.workspaceMember, memberBody);
+
+    const sourceKey = naturalKey(workspaceId, 'jd', 'fmode_gateway');
+    const source = await this.client.findOne(VOC_PARSE_CLASSES.sourceConnection, { naturalKey: sourceKey });
+    const sourceBody = {
+      naturalKey: sourceKey,
+      workspaceId,
+      platform: 'jd',
+      connectionKind: 'fmode_gateway',
+      status: 'configured',
+      metadata: { credentialStorage: 'environment' },
+    };
+    if (source) await this.client.update(VOC_PARSE_CLASSES.sourceConnection, source.objectId, sourceBody);
+    else await this.client.create(VOC_PARSE_CLASSES.sourceConnection, sourceBody);
+  }
+
+  private async purgeDataset(workspaceId: string, platform: string): Promise<void> {
+    for (const className of DATA_CLASSES) {
+      const objects = await this.client.findAll(className, { workspaceId, platform });
+      for (const batch of chunks(objects)) {
+        await this.client.batch(batch.map((object) => ({
+          method: 'DELETE',
+          path: `/classes/${className}/${object.objectId}`,
+        })));
+      }
+    }
+  }
+
+  private async createMany(className: string, objects: Array<Record<string, unknown>>): Promise<void> {
+    for (const batch of payloadChunks(objects)) {
+      await this.client.batch(batch.map((body) => ({
+        method: 'POST',
+        path: `/classes/${className}`,
+        body,
+      })));
+    }
+  }
+}

+ 202 - 0
src/modules/domestic-voc/services/parse-rest-snapshot.service.ts

@@ -0,0 +1,202 @@
+import { ParseRestClient } from '../../../db/parse-rest.client.js';
+import { VOC_PARSE_CLASSES } from '../../../db/parse-rest.schema.js';
+import type {
+  DomesticDataset,
+  DomesticDailyMetric,
+  DomesticMetricSummary,
+  DomesticProduct,
+  DomesticProductRelation,
+  DomesticReview,
+} from '../../../types/domestic-dataset.js';
+
+interface WorkspaceObject {
+  publicId: string;
+  caseName: string;
+  status: string;
+}
+
+interface ProductObject {
+  platform: string;
+  productId: string;
+  productKey: string;
+  role: 'own' | 'competitor';
+  brand: string;
+  title: string;
+  model: string;
+  category1: string;
+  category2: string;
+  category3: string;
+  source: string;
+  relationCount: number;
+  summary: DomesticMetricSummary;
+  trend: DomesticProduct['trend'];
+}
+
+interface RelationObject extends DomesticProductRelation {
+  ownModel: string;
+  ownCategory1: string;
+  ownCategory2: string;
+  ownCategory3: string;
+}
+
+interface ReviewObject {
+  productId: string;
+  sourceReviewId?: string;
+  reviewKey: string;
+  rating?: number;
+  content: string;
+  reviewDate?: string | { __type: 'Date'; iso: string };
+}
+
+interface ImportBatchObject {
+  sourceFile?: string;
+  sourceHash?: string;
+  sheets?: unknown[];
+  sourceDateStart?: string;
+  sourceDateEnd?: string;
+  productRows?: number;
+  metricRows?: number;
+  relationRows?: number;
+  reviewRows?: number;
+  dailyTotals?: DomesticDailyMetric[];
+  quality?: DomesticDataset['quality'];
+}
+
+const EMPTY_QUALITY: DomesticDataset['quality'] = {
+  orphanMappings: [],
+  mappingsWithoutCompetitor: [],
+  brandWithoutProductId: [],
+};
+
+function reviewDate(value: ReviewObject['reviewDate']): string | undefined {
+  if (typeof value === 'string' && value) return new Date(value).toISOString();
+  if (value && typeof value === 'object' && value.iso) return new Date(value.iso).toISOString();
+  return undefined;
+}
+
+export class ParseRestSnapshotService {
+  constructor(
+    private readonly client: ParseRestClient,
+    private readonly now: () => Date = () => new Date(),
+  ) {}
+
+  async getSnapshot(workspaceId: string, platform: string): Promise<DomesticDataset | null> {
+    const workspace = await this.client.findOne<WorkspaceObject>(VOC_PARSE_CLASSES.workspace, {
+      publicId: workspaceId,
+      status: 'active',
+    });
+    if (!workspace) return null;
+
+    const [productObjects, relationObjects, reviewObjects, batchResult] = await Promise.all([
+      this.client.findAll<ProductObject>(VOC_PARSE_CLASSES.product, {
+        workspaceId,
+        platform,
+        source: { $nin: ['relation_stub', 'sync_stub'] },
+      }),
+      this.client.findAll<RelationObject>(VOC_PARSE_CLASSES.productRelation, { workspaceId, platform }),
+      this.client.findAll<ReviewObject>(VOC_PARSE_CLASSES.review, { workspaceId, platform }),
+      this.client.find<ImportBatchObject>(VOC_PARSE_CLASSES.importBatch, {
+        where: { workspaceId, platform, status: { $in: ['completed', 'partial'] } },
+        order: '-createdAt',
+        limit: 1,
+      }),
+    ]);
+
+    const products: DomesticProduct[] = productObjects.map((product) => ({
+      platform: product.platform,
+      productId: product.productId,
+      productKey: product.productKey,
+      asin: product.productId,
+      role: product.role,
+      brand: product.brand,
+      title: product.title,
+      model: product.model,
+      category1: product.category1,
+      category2: product.category2,
+      category3: product.category3,
+      source: product.source,
+      relationCount: product.relationCount ?? 0,
+      summary: product.summary,
+      trend: product.trend ?? [],
+    }));
+
+    const relations: DomesticProductRelation[] = relationObjects.map((relation) => ({
+      relationKey: relation.relationKey,
+      ownProductKey: relation.ownProductKey,
+      ownProductId: relation.ownProductId,
+      competitorProductKey: relation.competitorProductKey,
+      competitorProductId: relation.competitorProductId,
+      competitorBrand: relation.competitorBrand,
+      category: relation.category,
+    }));
+    const mappingGroups = relationObjects.reduce<DomesticDataset['mappingGroups']>((groups, relation) => {
+      let group = groups.find((item) => item.ownProductId === relation.ownProductId);
+      if (!group) {
+        group = {
+          ownProductId: relation.ownProductId,
+          ownProductKey: relation.ownProductKey,
+          model: relation.ownModel ?? '',
+          category: relation.ownCategory3 || relation.ownCategory2 || relation.ownCategory1 || relation.category,
+          competitors: [],
+        };
+        groups.push(group);
+      }
+      group.competitors.push({
+        relationKey: relation.relationKey,
+        ownProductKey: relation.ownProductKey,
+        ownProductId: relation.ownProductId,
+        competitorProductKey: relation.competitorProductKey,
+        competitorProductId: relation.competitorProductId,
+        competitorBrand: relation.competitorBrand,
+        category: relation.category,
+      });
+      return groups;
+    }, []);
+
+    const reviews: DomesticReview[] = reviewObjects.map((review) => {
+      const value: DomesticReview = {
+        productId: review.productId,
+        reviewId: review.sourceReviewId || review.reviewKey,
+        rating: Number(review.rating ?? 0),
+        content: review.content,
+      };
+      const date = reviewDate(review.reviewDate);
+      return date ? { ...value, reviewDate: date } : value;
+    });
+    const batch = batchResult.results[0];
+    const ownIds = new Set(relations.map((relation) => relation.ownProductId));
+    const competitorIds = new Set(relations.map((relation) => relation.competitorProductId));
+
+    return {
+      schemaVersion: 1,
+      generatedAt: this.now().toISOString(),
+      caseName: workspace.caseName,
+      platform,
+      source: {
+        sourceFile: batch?.sourceFile || 'parse-rest',
+        sourceHash: batch?.sourceHash || '',
+        sheets: (batch?.sheets ?? []).filter((value): value is string => typeof value === 'string'),
+        dateRange: {
+          start: batch?.sourceDateStart || '',
+          end: batch?.sourceDateEnd || '',
+        },
+      },
+      summary: {
+        metricRows: batch?.metricRows ?? 0,
+        metricProducts: products.filter((product) => product.trend.length > 0).length,
+        mappingRows: ownIds.size,
+        relations: batch?.relationRows ?? relations.length,
+        uniqueCompetitorProducts: competitorIds.size,
+        category2Count: new Set(products.map((product) => product.category2).filter(Boolean)).size,
+        category3Count: new Set(products.map((product) => product.category3).filter(Boolean)).size,
+        reviewCount: batch?.reviewRows ?? reviews.length,
+      },
+      dailyTotals: batch?.dailyTotals ?? [],
+      products,
+      mappingGroups,
+      relations,
+      reviews,
+      quality: batch?.quality ?? EMPTY_QUALITY,
+    };
+  }
+}

+ 1 - 1
src/modules/saas-platform/auth.ts

@@ -24,7 +24,7 @@ export class DisabledAuthenticator implements RequestAuthenticator {
 
 export class ParseSessionAuthenticator implements RequestAuthenticator {
   constructor(
-    private readonly parse: AppConfig['parse'],
+    private readonly parse: Pick<AppConfig['parse'], 'appId' | 'serverUrl'>,
     private readonly fetchImplementation: typeof fetch = globalThis.fetch,
   ) {}
 

+ 891 - 0
src/modules/saas-platform/parse-rest-voc.repository.ts

@@ -0,0 +1,891 @@
+import { randomUUID } from 'node:crypto';
+import { ApiError } from '../../http/api-error.js';
+import { ParseRestClient, parseDate, parseDateIso } from '../../db/parse-rest.client.js';
+import { isVocSchemaReady, VOC_PARSE_CLASSES } from '../../db/parse-rest.schema.js';
+import type { DomesticDataset, DomesticProduct, DomesticReview } from '../../types/domestic-dataset.js';
+import type { JdProductRecord } from '../domestic-voc/adapters/jd-product.adapter.js';
+import type { JdReviewRecord } from '../domestic-voc/adapters/jd-review.adapter.js';
+import type {
+  EnqueueSyncJobInput,
+  SyncJobRecord,
+  SyncJobStore,
+} from '../domestic-voc/repositories/sync-job.repository.js';
+import type { SyncJobFinalStatus, SyncPersistence } from '../domestic-voc/repositories/voc-ingestion.repository.js';
+import { ParseRestSnapshotService } from '../domestic-voc/services/parse-rest-snapshot.service.js';
+import type {
+  ActionItem,
+  AlertItem,
+  AnalysisRun,
+  AuditEntry,
+  CursorPage,
+  DataSourceSummary,
+  DomesticProductDetail,
+  ImportBatchSummary,
+  PlatformRepository,
+  WorkspaceMember,
+  WorkspaceRole,
+  WorkspaceSummary,
+} from './domain.js';
+import { decodeCursor, pageFromSortedItems } from './pagination.js';
+
+interface WorkspaceObject {
+  publicId: string;
+  name: string;
+  caseName: string;
+  status: 'active' | 'disabled';
+}
+
+interface ParseRecordFields {
+  objectId: string;
+  createdAt: string;
+  updatedAt?: string;
+}
+
+interface MemberObject {
+  naturalKey: string;
+  workspaceId: string;
+  userId: string;
+  email: string;
+  displayName: string;
+  role: WorkspaceRole;
+  status: WorkspaceMember['status'];
+}
+
+interface ProductObject extends Omit<DomesticProduct, 'asin'> {
+  naturalKey: string;
+  workspaceId: string;
+}
+
+interface ReviewObject {
+  naturalKey: string;
+  workspaceId: string;
+  platform: string;
+  productId: string;
+  sourceReviewId?: string;
+  reviewKey: string;
+  rating?: number;
+  content: string;
+  reviewDate?: unknown;
+}
+
+interface RelationObject {
+  workspaceId: string;
+  platform: string;
+  relationKey: string;
+  ownProductKey: string;
+  ownProductId: string;
+  competitorProductKey: string;
+  competitorProductId: string;
+  competitorBrand: string;
+  category: string;
+}
+
+interface SyncJobObject extends ParseRecordFields {
+  publicId: string;
+  workspaceId: string;
+  platform: string;
+  idempotencyKey: string;
+  status: string;
+  scopes: string[];
+  productIds: string[];
+  progress: number;
+  attempts: number;
+  maxAttempts: number;
+  workerId?: string | null;
+  errorSummary?: string | null;
+  requestedAt: unknown;
+  startedAt?: unknown;
+  completedAt?: unknown;
+}
+
+interface SourceObject {
+  workspaceId: string;
+  platform: string;
+  connectionKind: string;
+  status: string;
+  metadata?: { credentialStorage?: string };
+  lastCheckedAt?: unknown;
+}
+
+interface ImportObject {
+  publicId: string;
+  workspaceId: string;
+  platform: string;
+  sourceKind: string;
+  sourceFile?: string;
+  status: string;
+  totalRows: number;
+  successRows: number;
+  failedRows: number;
+  completedAt?: unknown;
+}
+
+interface AnalysisObject extends ParseRecordFields {
+  publicId: string;
+  workspaceId: string;
+  analysisType: AnalysisRun['analysisType'];
+  targetKind: AnalysisRun['targetKind'];
+  targetKey: string;
+  status: AnalysisRun['status'];
+  input: Record<string, unknown>;
+  result?: Record<string, unknown>;
+  evidenceCount: number;
+  requestedBy: string;
+  errorSummary?: string;
+  requestedAt: unknown;
+  startedAt?: unknown;
+  completedAt?: unknown;
+}
+
+interface ActionObject extends ParseRecordFields {
+  publicId: string;
+  workspaceId: string;
+  actionType: ActionItem['actionType'];
+  title: string;
+  description: string;
+  priority: ActionItem['priority'];
+  status: ActionItem['status'];
+  productKey?: string | null;
+  assigneeUserId?: string | null;
+  dueAt?: unknown;
+  createdBy: string;
+  completedAt?: unknown;
+}
+
+interface AlertObject extends ParseRecordFields {
+  publicId: string;
+  workspaceId: string;
+  alertType: AlertItem['alertType'];
+  severity: AlertItem['severity'];
+  status: AlertItem['status'];
+  productKey?: string | null;
+  title: string;
+  summary: string;
+  evidence: unknown[];
+  detectedAt: unknown;
+  acknowledgedBy?: string;
+  acknowledgedAt?: unknown;
+  resolvedAt?: unknown;
+}
+
+interface AuditObject {
+  publicId: string;
+  workspaceId: string;
+  actorUserId: string;
+  action: string;
+  entityType: string;
+  entityId?: string;
+  metadata: Record<string, unknown>;
+}
+
+interface JobEventObject {
+  publicId: string;
+  workspaceId: string;
+  jobPublicId: string;
+  level: string;
+  eventType: string;
+  message: string;
+  details: Record<string, unknown>;
+}
+
+export interface ClaimedParseSyncJob {
+  internalId: string;
+  publicId: string;
+  workspaceId: string;
+  platform: string;
+  scopes: string[];
+  productIds: string[];
+  attempts: number;
+  maxAttempts: number;
+}
+
+function key(...parts: string[]): string {
+  return parts.map((part) => encodeURIComponent(part)).join('|');
+}
+
+function nullableDate(value: unknown): string | null {
+  return parseDateIso(value);
+}
+
+function requiredDate(value: unknown, fallback: string): string {
+  return nullableDate(value) ?? fallback;
+}
+
+function paginate<T>(items: T[], limit: number, cursor: string | null, getId: (item: T) => string): CursorPage<T> {
+  const decoded = decodeCursor(cursor);
+  if (cursor && !decoded) throw new ApiError(400, 'invalid_cursor');
+  const index = decoded ? items.findIndex((item) => getId(item) === decoded.id) : -1;
+  if (decoded && index < 0) throw new ApiError(400, 'invalid_cursor');
+  return pageFromSortedItems(items.slice(index + 1, index + limit + 2), limit, getId);
+}
+
+function mapReview(review: ReviewObject): DomesticReview {
+  const value: DomesticReview = {
+    productId: review.productId,
+    reviewId: review.sourceReviewId || review.reviewKey,
+    rating: Number(review.rating ?? 0),
+    content: review.content,
+  };
+  const date = nullableDate(review.reviewDate);
+  return date ? { ...value, reviewDate: date } : value;
+}
+
+export class ParseRestVocRepository implements PlatformRepository, SyncJobStore, SyncPersistence {
+  private readonly snapshot: ParseRestSnapshotService;
+
+  constructor(private readonly client: ParseRestClient) {
+    this.snapshot = new ParseRestSnapshotService(client);
+  }
+
+  async health(): Promise<{ ready: boolean; missingClasses: string[] }> {
+    if (!await this.client.health()) return { ready: false, missingClasses: [] };
+    const names = (await this.client.schemas()).map((schema) => schema.className);
+    const ready = isVocSchemaReady(names);
+    const available = new Set(names);
+    const required = Object.values(VOC_PARSE_CLASSES);
+    return { ready, missingClasses: ready ? [] : required.filter((name) => !available.has(name)) };
+  }
+
+  async getSnapshot(workspaceId: string, platform: string): Promise<DomesticDataset | null> {
+    return this.snapshot.getSnapshot(workspaceId, platform);
+  }
+
+  async getDatasetSnapshot(workspaceId: string, platform: string): Promise<DomesticDataset | null> {
+    return this.getSnapshot(workspaceId, platform);
+  }
+
+  async bootstrapAdmin(workspaceId: string, principal: { userId: string; email: string; displayName: string }): Promise<void> {
+    const workspace = await this.client.findOne<WorkspaceObject>(VOC_PARSE_CLASSES.workspace, { publicId: workspaceId });
+    if (!workspace) return;
+    await this.upsertMember({
+      workspaceId,
+      userId: principal.userId,
+      email: principal.email,
+      displayName: principal.displayName,
+      role: 'owner',
+      status: 'active',
+    });
+  }
+
+  async listWorkspaces(userId: string): Promise<WorkspaceSummary[]> {
+    const memberships = await this.client.findAll<MemberObject>(VOC_PARSE_CLASSES.workspaceMember, {
+      userId,
+      status: 'active',
+    });
+    const output: WorkspaceSummary[] = [];
+    for (const membership of memberships) {
+      const workspace = await this.client.findOne<WorkspaceObject>(VOC_PARSE_CLASSES.workspace, {
+        publicId: membership.workspaceId,
+      });
+      if (workspace) output.push({
+        id: workspace.publicId,
+        name: workspace.name,
+        caseName: workspace.caseName,
+        status: workspace.status,
+        role: membership.role,
+      });
+    }
+    return output.sort((left, right) => left.name.localeCompare(right.name));
+  }
+
+  async getMembership(workspaceId: string, userId: string): Promise<WorkspaceMember | null> {
+    const member = await this.client.findOne<MemberObject>(VOC_PARSE_CLASSES.workspaceMember, {
+      naturalKey: key(workspaceId, userId),
+    });
+    return member ? this.mapMember(member) : null;
+  }
+
+  async listMembers(workspaceId: string): Promise<WorkspaceMember[]> {
+    const members = await this.client.findAll<MemberObject>(VOC_PARSE_CLASSES.workspaceMember, { workspaceId });
+    return members.map((member) => this.mapMember(member)).sort((left, right) => left.createdAt.localeCompare(right.createdAt));
+  }
+
+  async countActiveOwners(workspaceId: string): Promise<number> {
+    return this.client.count(VOC_PARSE_CLASSES.workspaceMember, { workspaceId, role: 'owner', status: 'active' });
+  }
+
+  async upsertMember(input: {
+    workspaceId: string;
+    userId: string;
+    email: string;
+    displayName: string;
+    role: WorkspaceRole;
+    status: WorkspaceMember['status'];
+  }): Promise<WorkspaceMember | null> {
+    const workspace = await this.client.findOne(VOC_PARSE_CLASSES.workspace, { publicId: input.workspaceId });
+    if (!workspace) return null;
+    const naturalKey = key(input.workspaceId, input.userId);
+    const existing = await this.client.findOne<MemberObject>(VOC_PARSE_CLASSES.workspaceMember, { naturalKey });
+    const body = { naturalKey, ...input };
+    if (existing) {
+      const result = await this.client.update(VOC_PARSE_CLASSES.workspaceMember, existing.objectId, body);
+      return this.mapMember({ ...existing, ...body, updatedAt: result.updatedAt });
+    }
+    const created = await this.client.create(VOC_PARSE_CLASSES.workspaceMember, body);
+    return this.mapMember({ ...body, ...created });
+  }
+
+  async listProducts(input: {
+    workspaceId: string;
+    platform: string;
+    limit: number;
+    cursor: string | null;
+    search: string;
+    role?: DomesticProduct['role'];
+    category: string;
+  }): Promise<CursorPage<DomesticProduct>> {
+    const search = input.search.toLocaleLowerCase();
+    const products = (await this.client.findAll<ProductObject>(VOC_PARSE_CLASSES.product, {
+      workspaceId: input.workspaceId,
+      platform: input.platform,
+      source: { $nin: ['relation_stub', 'sync_stub'] },
+    }))
+      .filter((product) => !input.role || product.role === input.role)
+      .filter((product) => !input.category || [product.category1, product.category2, product.category3].includes(input.category))
+      .filter((product) => !search || [product.productId, product.model, product.title, product.brand]
+        .some((value) => value.toLocaleLowerCase().includes(search)))
+      .map((product) => this.mapProduct(product))
+      .sort((left, right) => left.productKey.localeCompare(right.productKey));
+    return paginate(products, input.limit, input.cursor, (product) => product.productKey);
+  }
+
+  async getProduct(workspaceId: string, platform: string, productId: string): Promise<DomesticProductDetail | null> {
+    const product = await this.client.findOne<ProductObject>(VOC_PARSE_CLASSES.product, { workspaceId, platform, productId });
+    if (!product) return null;
+    const reviews = await this.client.findAll<ReviewObject>(VOC_PARSE_CLASSES.review, { workspaceId, platform, productId });
+    return {
+      ...this.mapProduct(product),
+      reviews: {
+        count: reviews.length,
+        averageRating: reviews.length
+          ? reviews.reduce((total, review) => total + Number(review.rating ?? 0), 0) / reviews.length
+          : 0,
+      },
+    };
+  }
+
+  async listReviews(input: { workspaceId: string; platform: string; productId: string; limit: number; cursor: string | null }): Promise<CursorPage<DomesticReview>> {
+    const reviews = (await this.client.findAll<ReviewObject>(VOC_PARSE_CLASSES.review, {
+      workspaceId: input.workspaceId,
+      platform: input.platform,
+      productId: input.productId,
+    })).map(mapReview).sort((left, right) => left.reviewId.localeCompare(right.reviewId));
+    return paginate(reviews, input.limit, input.cursor, (review) => review.reviewId);
+  }
+
+  async listRelations(input: { workspaceId: string; platform: string; limit: number; cursor: string | null }) {
+    const relations = (await this.client.findAll<RelationObject>(VOC_PARSE_CLASSES.productRelation, {
+      workspaceId: input.workspaceId,
+      platform: input.platform,
+    })).map((relation) => ({
+      relationKey: relation.relationKey,
+      ownProductKey: relation.ownProductKey,
+      ownProductId: relation.ownProductId,
+      competitorProductKey: relation.competitorProductKey,
+      competitorProductId: relation.competitorProductId,
+      competitorBrand: relation.competitorBrand,
+      category: relation.category,
+    })).sort((left, right) => left.relationKey.localeCompare(right.relationKey));
+    return paginate(relations, input.limit, input.cursor, (relation) => relation.relationKey);
+  }
+
+  async enqueue(input: EnqueueSyncJobInput): Promise<SyncJobRecord | null> {
+    const workspace = await this.client.findOne<WorkspaceObject>(VOC_PARSE_CLASSES.workspace, {
+      publicId: input.workspaceId,
+      status: 'active',
+    });
+    if (!workspace) return null;
+    const existing = await this.client.findOne<SyncJobObject>(VOC_PARSE_CLASSES.syncJob, {
+      workspaceId: input.workspaceId,
+      idempotencyKey: input.idempotencyKey,
+    });
+    if (existing) return this.mapJob(existing);
+    const timestamp = new Date();
+    const body = {
+      publicId: input.publicId,
+      workspaceId: input.workspaceId,
+      platform: input.platform,
+      idempotencyKey: input.idempotencyKey,
+      status: 'pending',
+      scopes: input.scopes,
+      productIds: input.productIds,
+      progress: 0,
+      attempts: 0,
+      maxAttempts: 3,
+      requestedAt: parseDate(timestamp),
+    };
+    const created = await this.client.create(VOC_PARSE_CLASSES.syncJob, body);
+    return this.mapJob({ ...body, ...created });
+  }
+
+  async findByPublicId(publicId: string): Promise<SyncJobRecord | null> {
+    const job = await this.client.findOne<SyncJobObject>(VOC_PARSE_CLASSES.syncJob, { publicId });
+    return job ? this.mapJob(job) : null;
+  }
+
+  async retry(workspaceId: string, publicId: string): Promise<SyncJobRecord | null> {
+    const job = await this.client.findOne<SyncJobObject>(VOC_PARSE_CLASSES.syncJob, { workspaceId, publicId });
+    if (!job || !['partial', 'failed', 'cancelled'].includes(job.status)) return null;
+    const patch = {
+      status: 'pending', progress: 0, attempts: 0, workerId: null, errorSummary: null,
+      requestedAt: parseDate(new Date()), startedAt: null, completedAt: null,
+    };
+    const updated = await this.client.update(VOC_PARSE_CLASSES.syncJob, job.objectId, patch);
+    await this.createJobEvent(workspaceId, publicId, 'info', 'sync_manually_retried', 'Sync job queued for manual retry', { manual: true });
+    return this.mapJob({ ...job, ...patch, updatedAt: updated.updatedAt });
+  }
+
+  async cancel(workspaceId: string, publicId: string): Promise<SyncJobRecord | null> {
+    const job = await this.client.findOne<SyncJobObject>(VOC_PARSE_CLASSES.syncJob, { workspaceId, publicId });
+    if (!job || job.status !== 'pending') return null;
+    const patch = { status: 'cancelled', workerId: null, completedAt: parseDate(new Date()) };
+    const updated = await this.client.update(VOC_PARSE_CLASSES.syncJob, job.objectId, patch);
+    await this.createJobEvent(workspaceId, publicId, 'info', 'sync_manually_cancelled', 'Pending sync job cancelled', { manual: true });
+    return this.mapJob({ ...job, ...patch, updatedAt: updated.updatedAt });
+  }
+
+  async listJobs(input: { workspaceId: string; limit: number; cursor: string | null; status: string }) {
+    const jobs = (await this.client.findAll<SyncJobObject>(VOC_PARSE_CLASSES.syncJob, { workspaceId: input.workspaceId }))
+      .filter((job) => !input.status || job.status === input.status)
+      .map((job) => this.mapJob(job))
+      .sort((left, right) => right.requestedAt.localeCompare(left.requestedAt));
+    return paginate(jobs, input.limit, input.cursor, (job) => job.id);
+  }
+
+  async listJobEvents(workspaceId: string, jobId: string) {
+    const events = await this.client.findAll<JobEventObject>(VOC_PARSE_CLASSES.syncJobEvent, {
+      workspaceId,
+      jobPublicId: jobId,
+    });
+    return events.sort((left, right) => left.createdAt.localeCompare(right.createdAt)).map((event) => ({
+      id: event.publicId,
+      level: event.level,
+      type: event.eventType,
+      message: event.message,
+      details: event.details ?? {},
+      createdAt: event.createdAt,
+    }));
+  }
+
+  async listDataSources(workspaceId: string): Promise<DataSourceSummary[]> {
+    const sources = await this.client.findAll<SourceObject>(VOC_PARSE_CLASSES.sourceConnection, { workspaceId });
+    return sources.map((source) => ({
+      id: source.objectId,
+      workspaceId: source.workspaceId,
+      platform: source.platform,
+      kind: source.connectionKind,
+      status: source.status,
+      lastCheckedAt: nullableDate(source.lastCheckedAt),
+      credentialStorage: source.metadata?.credentialStorage === 'external_secret' ? 'external_secret' : 'environment',
+    }));
+  }
+
+  async listImports(input: { workspaceId: string; limit: number; cursor: string | null }): Promise<CursorPage<ImportBatchSummary>> {
+    const imports = (await this.client.findAll<ImportObject>(VOC_PARSE_CLASSES.importBatch, { workspaceId: input.workspaceId }))
+      .map((item): ImportBatchSummary => ({
+        id: item.publicId,
+        workspaceId: item.workspaceId,
+        platform: item.platform,
+        sourceKind: item.sourceKind,
+        sourceFile: item.sourceFile ?? null,
+        status: item.status,
+        totalRows: item.totalRows,
+        successRows: item.successRows,
+        failedRows: item.failedRows,
+        createdAt: item.createdAt,
+        completedAt: nullableDate(item.completedAt),
+      })).sort((left, right) => right.createdAt.localeCompare(left.createdAt));
+    return paginate(imports, input.limit, input.cursor, (item) => item.id);
+  }
+
+  async listAnalyses(input: { workspaceId: string; limit: number; cursor: string | null; status: string }) {
+    const items = (await this.client.findAll<AnalysisObject>(VOC_PARSE_CLASSES.analysisRun, { workspaceId: input.workspaceId }))
+      .filter((item) => !input.status || item.status === input.status)
+      .map((item) => this.mapAnalysis(item))
+      .sort((left, right) => right.requestedAt.localeCompare(left.requestedAt));
+    return paginate(items, input.limit, input.cursor, (item) => item.id);
+  }
+
+  async createAnalysis(input: Omit<AnalysisRun, 'status' | 'result' | 'evidenceCount' | 'errorSummary' | 'requestedAt' | 'startedAt' | 'completedAt'>): Promise<AnalysisRun> {
+    const requestedAt = new Date();
+    const body = {
+      publicId: input.id,
+      workspaceId: input.workspaceId,
+      analysisType: input.analysisType,
+      targetKind: input.targetKind,
+      targetKey: input.targetKey,
+      status: 'pending' as const,
+      input: input.input,
+      evidenceCount: 0,
+      requestedBy: input.requestedBy,
+      requestedAt: parseDate(requestedAt),
+    };
+    const created = await this.client.create(VOC_PARSE_CLASSES.analysisRun, body);
+    return this.mapAnalysis({ ...body, ...created });
+  }
+
+  async listActions(input: { workspaceId: string; limit: number; cursor: string | null; status: string }) {
+    const items = (await this.client.findAll<ActionObject>(VOC_PARSE_CLASSES.actionItem, { workspaceId: input.workspaceId }))
+      .filter((item) => !input.status || item.status === input.status)
+      .map((item) => this.mapAction(item))
+      .sort((left, right) => right.createdAt.localeCompare(left.createdAt));
+    return paginate(items, input.limit, input.cursor, (item) => item.id);
+  }
+
+  async createAction(input: Omit<ActionItem, 'completedAt' | 'createdAt' | 'updatedAt'>): Promise<ActionItem> {
+    const body = {
+      publicId: input.id,
+      workspaceId: input.workspaceId,
+      actionType: input.actionType,
+      title: input.title,
+      description: input.description,
+      priority: input.priority,
+      status: input.status,
+      productKey: input.productKey,
+      assigneeUserId: input.assigneeUserId,
+      dueAt: input.dueAt ? parseDate(input.dueAt) : null,
+      createdBy: input.createdBy,
+      completedAt: input.status === 'completed' ? parseDate(new Date()) : null,
+    };
+    const created = await this.client.create(VOC_PARSE_CLASSES.actionItem, body);
+    return this.mapAction({ ...body, ...created });
+  }
+
+  async updateAction(workspaceId: string, id: string, patch: Partial<Pick<ActionItem, 'title' | 'description' | 'priority' | 'status' | 'assigneeUserId' | 'dueAt'>>): Promise<ActionItem | null> {
+    const action = await this.client.findOne<ActionObject>(VOC_PARSE_CLASSES.actionItem, { workspaceId, publicId: id });
+    if (!action) return null;
+    const body: Record<string, unknown> = { ...patch };
+    if ('dueAt' in patch) body.dueAt = patch.dueAt ? parseDate(patch.dueAt) : null;
+    if (patch.status) body.completedAt = patch.status === 'completed' ? parseDate(new Date()) : null;
+    const result = await this.client.update(VOC_PARSE_CLASSES.actionItem, action.objectId, body);
+    return this.mapAction({ ...action, ...body, updatedAt: result.updatedAt } as ActionObject & typeof action);
+  }
+
+  async listAlerts(input: { workspaceId: string; limit: number; cursor: string | null; status: string }) {
+    const items = (await this.client.findAll<AlertObject>(VOC_PARSE_CLASSES.alert, { workspaceId: input.workspaceId }))
+      .filter((item) => !input.status || item.status === input.status)
+      .map((item) => this.mapAlert(item))
+      .sort((left, right) => right.detectedAt.localeCompare(left.detectedAt));
+    return paginate(items, input.limit, input.cursor, (item) => item.id);
+  }
+
+  async createAlert(input: Omit<AlertItem, 'detectedAt' | 'acknowledgedBy' | 'acknowledgedAt' | 'resolvedAt'>): Promise<AlertItem> {
+    const body = {
+      publicId: input.id,
+      workspaceId: input.workspaceId,
+      alertType: input.alertType,
+      severity: input.severity,
+      status: input.status,
+      productKey: input.productKey,
+      title: input.title,
+      summary: input.summary,
+      evidence: input.evidence,
+      detectedAt: parseDate(new Date()),
+    };
+    const created = await this.client.create(VOC_PARSE_CLASSES.alert, body);
+    return this.mapAlert({ ...body, ...created });
+  }
+
+  async updateAlert(workspaceId: string, id: string, patch: { status: AlertItem['status']; actorUserId: string }): Promise<AlertItem | null> {
+    const alert = await this.client.findOne<AlertObject>(VOC_PARSE_CLASSES.alert, { workspaceId, publicId: id });
+    if (!alert) return null;
+    const timestamp = parseDate(new Date());
+    const body = {
+      status: patch.status,
+      ...(patch.status === 'acknowledged' ? { acknowledgedBy: patch.actorUserId, acknowledgedAt: timestamp } : {}),
+      ...(patch.status === 'resolved' ? { resolvedAt: timestamp } : {}),
+    };
+    const result = await this.client.update(VOC_PARSE_CLASSES.alert, alert.objectId, body);
+    return this.mapAlert({ ...alert, ...body, updatedAt: result.updatedAt });
+  }
+
+  async listAudit(input: { workspaceId: string; limit: number; cursor: string | null }) {
+    const items = (await this.client.findAll<AuditObject>(VOC_PARSE_CLASSES.auditLog, { workspaceId: input.workspaceId }))
+      .map((item): AuditEntry => ({
+        id: item.publicId,
+        workspaceId: item.workspaceId,
+        actorUserId: item.actorUserId,
+        action: item.action,
+        entityType: item.entityType,
+        entityId: item.entityId ?? null,
+        metadata: item.metadata ?? {},
+        createdAt: item.createdAt,
+      })).sort((left, right) => right.createdAt.localeCompare(left.createdAt));
+    return paginate(items, input.limit, input.cursor, (item) => item.id);
+  }
+
+  async appendAudit(input: Omit<AuditEntry, 'id' | 'createdAt'>): Promise<void> {
+    await this.client.create(VOC_PARSE_CLASSES.auditLog, {
+      publicId: randomUUID(),
+      workspaceId: input.workspaceId,
+      actorUserId: input.actorUserId,
+      action: input.action,
+      entityType: input.entityType,
+      entityId: input.entityId,
+      metadata: input.metadata,
+    });
+  }
+
+  async recoverStaleJobs(staleAfterMs: number): Promise<number> {
+    const cutoff = new Date(Date.now() - staleAfterMs);
+    const jobs = await this.client.findAll<SyncJobObject>(VOC_PARSE_CLASSES.syncJob, {
+      status: 'processing',
+      updatedAt: { $lt: parseDate(cutoff) },
+    });
+    for (const job of jobs) {
+      const retry = job.attempts < job.maxAttempts;
+      await this.client.update(VOC_PARSE_CLASSES.syncJob, job.objectId, {
+        status: retry ? 'pending' : 'failed',
+        workerId: null,
+        errorSummary: 'Worker interrupted; stale job recovered',
+        completedAt: retry ? null : parseDate(new Date()),
+      });
+      await this.createJobEvent(job.workspaceId, job.publicId, retry ? 'warning' : 'error', 'sync_stale_recovered',
+        retry ? 'Interrupted sync job returned to the queue' : 'Interrupted sync job exhausted retry attempts',
+        { status: retry ? 'pending' : 'failed' });
+    }
+    return jobs.length;
+  }
+
+  async claimNextJob(workerId: string): Promise<ClaimedParseSyncJob | null> {
+    const response = await this.client.find<SyncJobObject>(VOC_PARSE_CLASSES.syncJob, {
+      where: { status: 'pending', attempts: { $lt: 3 } },
+      order: 'requestedAt,createdAt',
+      limit: 1,
+    });
+    const job = response.results[0];
+    if (!job) return null;
+    const attempts = job.attempts + 1;
+    await this.client.update(VOC_PARSE_CLASSES.syncJob, job.objectId, {
+      status: 'processing',
+      workerId,
+      attempts,
+      startedAt: job.startedAt ?? parseDate(new Date()),
+    });
+    return {
+      internalId: job.objectId,
+      publicId: job.publicId,
+      workspaceId: job.workspaceId,
+      platform: job.platform,
+      scopes: job.scopes,
+      productIds: job.productIds,
+      attempts,
+      maxAttempts: job.maxAttempts,
+    };
+  }
+
+  async upsertProduct(workspaceId: string, product: JdProductRecord): Promise<void> {
+    const naturalKey = key(workspaceId, product.platform, product.productId);
+    const existing = await this.client.findOne<ProductObject>(VOC_PARSE_CLASSES.product, { naturalKey });
+    const body = {
+      naturalKey,
+      workspaceId,
+      platform: product.platform,
+      productId: product.productId,
+      productKey: product.productKey,
+      role: product.role,
+      brand: product.brand || existing?.brand || '',
+      title: product.title || existing?.title || '',
+      model: product.model || existing?.model || '',
+      category1: product.category1 || existing?.category1 || '',
+      category2: product.category2 || existing?.category2 || '',
+      category3: product.category3 || existing?.category3 || '',
+      source: product.source,
+      relationCount: existing?.relationCount ?? 0,
+      summary: existing?.summary ?? {},
+      trend: existing?.trend ?? [],
+      rawPayload: product.rawPayload,
+    };
+    if (existing) await this.client.update(VOC_PARSE_CLASSES.product, existing.objectId, body);
+    else await this.client.create(VOC_PARSE_CLASSES.product, body);
+  }
+
+  async ensureProductStub(workspaceId: string, platform: string, productId: string): Promise<void> {
+    const workspace = await this.client.findOne(VOC_PARSE_CLASSES.workspace, { publicId: workspaceId, status: 'active' });
+    if (!workspace) throw new Error(`Workspace not found: ${workspaceId}`);
+    const naturalKey = key(workspaceId, platform, productId);
+    if (await this.client.findOne(VOC_PARSE_CLASSES.product, { naturalKey })) return;
+    await this.client.create(VOC_PARSE_CLASSES.product, {
+      naturalKey, workspaceId, platform, productId, productKey: `${platform}:${productId}`,
+      role: 'own', brand: '', title: '', model: '', category1: '', category2: '', category3: '',
+      source: 'sync_stub', relationCount: 0, summary: {}, trend: [],
+    });
+  }
+
+  async upsertReviews(workspaceId: string, platform: string, productId: string, reviews: JdReviewRecord[]): Promise<number> {
+    for (const review of reviews) {
+      const naturalKey = key(workspaceId, platform, review.reviewKey);
+      const existing = await this.client.findOne<ReviewObject>(VOC_PARSE_CLASSES.review, { naturalKey });
+      const body = {
+        naturalKey, workspaceId, platform, productId,
+        sourceReviewId: review.reviewId || null,
+        reviewKey: review.reviewKey,
+        rating: review.rating,
+        content: review.content,
+        reviewDate: review.reviewDate ? parseDate(review.reviewDate) : null,
+        rawPayload: review.rawPayload,
+      };
+      if (existing) await this.client.update(VOC_PARSE_CLASSES.review, existing.objectId, body);
+      else await this.client.create(VOC_PARSE_CLASSES.review, body);
+    }
+    return reviews.length;
+  }
+
+  async setJobProgress(jobInternalId: string, progress: number): Promise<void> {
+    await this.client.update(VOC_PARSE_CLASSES.syncJob, jobInternalId, {
+      progress: Math.max(0, Math.min(100, Math.round(progress))),
+    });
+  }
+
+  async finishJob(jobInternalId: string, status: SyncJobFinalStatus, errorSummary = ''): Promise<void> {
+    await this.client.update(VOC_PARSE_CLASSES.syncJob, jobInternalId, {
+      status,
+      progress: 100,
+      errorSummary: errorSummary ? errorSummary.slice(0, 1_000) : null,
+      completedAt: parseDate(new Date()),
+    });
+  }
+
+  async requeueJob(jobInternalId: string, errorSummary: string): Promise<void> {
+    await this.client.update(VOC_PARSE_CLASSES.syncJob, jobInternalId, {
+      status: 'pending',
+      progress: 0,
+      workerId: null,
+      errorSummary: errorSummary.slice(0, 1_000),
+    });
+  }
+
+  async addJobEvent(input: {
+    jobInternalId: string;
+    level: 'info' | 'warning' | 'error';
+    eventType: string;
+    message: string;
+    details?: Record<string, unknown>;
+  }): Promise<void> {
+    const job = await this.client.findOne<SyncJobObject>(VOC_PARSE_CLASSES.syncJob, { objectId: input.jobInternalId });
+    if (!job) return;
+    await this.createJobEvent(job.workspaceId, job.publicId, input.level, input.eventType, input.message, input.details ?? {});
+  }
+
+  private async createJobEvent(
+    workspaceId: string,
+    jobPublicId: string,
+    level: string,
+    eventType: string,
+    message: string,
+    details: Record<string, unknown>,
+  ): Promise<void> {
+    await this.client.create(VOC_PARSE_CLASSES.syncJobEvent, {
+      publicId: randomUUID(), workspaceId, jobPublicId, level, eventType,
+      message: message.slice(0, 1_000), details,
+    });
+  }
+
+  private mapMember(member: MemberObject & { objectId: string; createdAt: string; updatedAt: string }): WorkspaceMember {
+    return {
+      id: member.objectId,
+      workspaceId: member.workspaceId,
+      userId: member.userId,
+      email: member.email,
+      displayName: member.displayName,
+      role: member.role,
+      status: member.status,
+      createdAt: member.createdAt,
+      updatedAt: member.updatedAt,
+    };
+  }
+
+  private mapProduct(product: ProductObject): DomesticProduct {
+    return {
+      platform: product.platform,
+      productId: product.productId,
+      productKey: product.productKey,
+      asin: product.productId,
+      role: product.role,
+      brand: product.brand,
+      title: product.title,
+      model: product.model,
+      category1: product.category1,
+      category2: product.category2,
+      category3: product.category3,
+      source: product.source,
+      relationCount: product.relationCount ?? 0,
+      summary: product.summary,
+      trend: product.trend ?? [],
+    };
+  }
+
+  private mapJob(job: SyncJobObject): SyncJobRecord {
+    return {
+      id: job.publicId,
+      workspaceId: job.workspaceId,
+      platform: job.platform,
+      status: job.status,
+      scopes: job.scopes ?? [],
+      productIds: job.productIds ?? [],
+      progress: Number(job.progress ?? 0),
+      attempts: Number(job.attempts ?? 0),
+      maxAttempts: Number(job.maxAttempts ?? 3),
+      errorSummary: job.errorSummary || null,
+      requestedAt: requiredDate(job.requestedAt, job.createdAt),
+      startedAt: nullableDate(job.startedAt),
+      completedAt: nullableDate(job.completedAt),
+    };
+  }
+
+  private mapAnalysis(item: AnalysisObject): AnalysisRun {
+    return {
+      id: item.publicId,
+      workspaceId: item.workspaceId,
+      analysisType: item.analysisType,
+      targetKind: item.targetKind,
+      targetKey: item.targetKey,
+      status: item.status,
+      input: item.input ?? {},
+      result: item.result ?? null,
+      evidenceCount: Number(item.evidenceCount ?? 0),
+      requestedBy: item.requestedBy,
+      errorSummary: item.errorSummary ?? null,
+      requestedAt: requiredDate(item.requestedAt, item.createdAt),
+      startedAt: nullableDate(item.startedAt),
+      completedAt: nullableDate(item.completedAt),
+    };
+  }
+
+  private mapAction(item: ActionObject): ActionItem {
+    return {
+      id: item.publicId,
+      workspaceId: item.workspaceId,
+      actionType: item.actionType,
+      title: item.title,
+      description: item.description,
+      priority: item.priority,
+      status: item.status,
+      productKey: item.productKey ?? null,
+      assigneeUserId: item.assigneeUserId ?? null,
+      dueAt: nullableDate(item.dueAt),
+      createdBy: item.createdBy,
+      completedAt: nullableDate(item.completedAt),
+      createdAt: item.createdAt,
+      updatedAt: item.updatedAt ?? item.createdAt,
+    };
+  }
+
+  private mapAlert(item: AlertObject): AlertItem {
+    return {
+      id: item.publicId,
+      workspaceId: item.workspaceId,
+      alertType: item.alertType,
+      severity: item.severity,
+      status: item.status,
+      productKey: item.productKey ?? null,
+      title: item.title,
+      summary: item.summary,
+      evidence: item.evidence ?? [],
+      detectedAt: requiredDate(item.detectedAt, item.createdAt),
+      acknowledgedBy: item.acknowledgedBy ?? null,
+      acknowledgedAt: nullableDate(item.acknowledgedAt),
+      resolvedAt: nullableDate(item.resolvedAt),
+    };
+  }
+}

+ 80 - 29
src/server.ts

@@ -5,46 +5,98 @@ import { createApp } from './app.js';
 import { loadConfig } from './config/env.js';
 import { createParseServer } from './config/parse.js';
 import { createDatabasePool } from './db/pool.js';
+import { ParseRestClient } from './db/parse-rest.client.js';
+import { ensureVocParseSchemas } from './db/parse-rest.schema.js';
+import { startParseRestSyncWorker } from './modules/domestic-voc/jobs/parse-rest-sync-worker.js';
 import { startSyncWorker } from './modules/domestic-voc/jobs/sync-worker.js';
 import { VocIngestionRepository } from './modules/domestic-voc/repositories/voc-ingestion.repository.js';
 import { JdSyncService } from './modules/domestic-voc/services/jd-sync.service.js';
 import { FmodeVocEcommerceClient } from './modules/domestic-voc/upstream/fmode-client.js';
+import { ParseRestVocRepository } from './modules/saas-platform/parse-rest-voc.repository.js';
 import { PostgresPlatformRepository } from './modules/saas-platform/postgres-platform.repository.js';
 
 async function main(): Promise<void> {
   const config = loadConfig();
-  const pool = createDatabasePool(config);
-  const parseServer = await createParseServer(config);
-  const platformRepository = new PostgresPlatformRepository(pool);
   const bootstrapUserId = config.auth.bootstrapAdminUserId
     || (config.auth.mode === 'disabled' ? config.auth.localUserId : '');
-  if (bootstrapUserId) {
-    await platformRepository.bootstrapAdmin(config.auth.defaultWorkspaceId, {
-      userId: bootstrapUserId,
-      email: config.auth.bootstrapAdminEmail
-        || (config.auth.mode === 'disabled' ? config.auth.localUserEmail : ''),
-      displayName: config.auth.bootstrapAdminName
-        || (config.auth.mode === 'disabled' ? config.auth.localUserName : bootstrapUserId),
+  const gateway = new FmodeVocEcommerceClient(config.fmode);
+  let app: ReturnType<typeof createApp>;
+  let worker: { stop(): Promise<void> } | null = null;
+  let closeStorage = async () => undefined;
+
+  if (config.storageDriver === 'parse_rest') {
+    const client = new ParseRestClient({
+      serverUrl: config.parse.serverUrl,
+      appId: config.parse.appId,
+      masterKey: config.parse.masterKey,
+      timeoutMs: config.parse.timeoutMs,
     });
+    await ensureVocParseSchemas(client);
+    const repository = new ParseRestVocRepository(client);
+    if (bootstrapUserId) {
+      await repository.bootstrapAdmin(config.auth.defaultWorkspaceId, {
+        userId: bootstrapUserId,
+        email: config.auth.bootstrapAdminEmail
+          || (config.auth.mode === 'disabled' ? config.auth.localUserEmail : ''),
+        displayName: config.auth.bootstrapAdminName
+          || (config.auth.mode === 'disabled' ? config.auth.localUserName : bootstrapUserId),
+      });
+    }
+    app = createApp({
+      config,
+      platformRepository: repository,
+      jobs: repository,
+      snapshot: repository,
+      healthCheck: async () => {
+        const health = await repository.health();
+        return { ready: health.ready, missingObjects: health.missingClasses };
+      },
+    });
+    const processor = new JdSyncService(gateway, repository, config.worker.reviewMaxPages);
+    worker = config.worker.enabled
+      ? startParseRestSyncWorker({
+        queue: repository,
+        processor,
+        pollMs: config.worker.pollMs,
+        staleAfterMs: config.worker.staleAfterMs,
+      })
+      : null;
+  } else {
+    const pool = createDatabasePool(config);
+    const parseServer = await createParseServer(config);
+    const platformRepository = new PostgresPlatformRepository(pool);
+    if (bootstrapUserId) {
+      await platformRepository.bootstrapAdmin(config.auth.defaultWorkspaceId, {
+        userId: bootstrapUserId,
+        email: config.auth.bootstrapAdminEmail
+          || (config.auth.mode === 'disabled' ? config.auth.localUserEmail : ''),
+        displayName: config.auth.bootstrapAdminName
+          || (config.auth.mode === 'disabled' ? config.auth.localUserName : bootstrapUserId),
+      });
+    }
+    app = createApp({
+      config,
+      pool,
+      parseApp: parseServer.app as unknown as RequestHandler,
+      platformRepository,
+    });
+    const ingestion = new VocIngestionRepository(pool);
+    const processor = new JdSyncService(gateway, ingestion, config.worker.reviewMaxPages);
+    worker = config.worker.enabled
+      ? startSyncWorker({
+        pool,
+        processor,
+        pollMs: config.worker.pollMs,
+        staleAfterMs: config.worker.staleAfterMs,
+      })
+      : null;
+    closeStorage = async () => {
+      await parseServer.handleShutdown();
+      await pool.end();
+    };
   }
-  const app = createApp({
-    config,
-    pool,
-    parseApp: parseServer.app as unknown as RequestHandler,
-    platformRepository,
-  });
+
   const server = createServer(app);
-  const gateway = new FmodeVocEcommerceClient(config.fmode);
-  const ingestion = new VocIngestionRepository(pool);
-  const processor = new JdSyncService(gateway, ingestion, config.worker.reviewMaxPages);
-  const worker = config.worker.enabled
-    ? startSyncWorker({
-      pool,
-      processor,
-      pollMs: config.worker.pollMs,
-      staleAfterMs: config.worker.staleAfterMs,
-    })
-    : null;
 
   server.listen(config.port, config.host, () => {
     console.log(`[server] listening on http://${config.host}:${config.port}`);
@@ -54,8 +106,7 @@ async function main(): Promise<void> {
     console.log(`[server] received ${signal}; shutting down`);
     await worker?.stop();
     server.close(async () => {
-      await parseServer.handleShutdown();
-      await pool.end();
+      await closeStorage();
       process.exit(0);
     });
   };

+ 16 - 0
test/env.test.ts

@@ -65,6 +65,22 @@ test('loadConfig treats blank optional environment values as unset', () => {
   assert.equal(config.auth.bootstrapAdminUserId, '');
 });
 
+test('loadConfig supports Parse REST storage without a direct database connection', () => {
+  const environment: NodeJS.ProcessEnv = { ...validEnvironment, STORAGE_DRIVER: 'parse_rest' };
+  delete environment.DATABASE_URL;
+  delete environment.MIGRATION_DATABASE_URL;
+  delete environment.PARSE_MAINTENANCE_KEY;
+  environment.PARSE_APP_ID = 'dev';
+  environment.PARSE_MASTER_KEY = 'short-test-key';
+  environment.PARSE_SERVER_URL = 'http://dev.example.test/parse';
+
+  const config = loadConfig(environment);
+
+  assert.equal(config.storageDriver, 'parse_rest');
+  assert.equal(config.database.url, '');
+  assert.equal(config.parse.serverUrl, 'http://dev.example.test/parse');
+});
+
 test('loadConfig rejects disabled authentication in production', () => {
   assert.throws(
     () => loadConfig({ ...validEnvironment, NODE_ENV: 'production', API_AUTH_MODE: 'disabled' }),

+ 77 - 0
test/parse-rest-client.test.ts

@@ -0,0 +1,77 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { ParseRestClient, ParseRestError } from '../src/db/parse-rest.client.js';
+
+test('Parse REST client keeps the master key server-side and encodes structured queries', async () => {
+  const calls: Array<{ url: string; init: RequestInit }> = [];
+  const fetchMock: typeof fetch = async (input, init = {}) => {
+    calls.push({ url: String(input), init });
+    return new Response(JSON.stringify({ results: [] }), {
+      status: 200,
+      headers: { 'Content-Type': 'application/json' },
+    });
+  };
+  const client = new ParseRestClient({
+    serverUrl: 'https://parse.example.test/parse/',
+    appId: 'app-id',
+    masterKey: 'master-secret',
+  }, fetchMock);
+
+  await client.find('VocProduct', {
+    where: { workspaceId: 'demashi', platform: 'jd' },
+    order: 'productKey',
+    limit: 25,
+  });
+  await client.request('/health', { master: false });
+
+  const queryUrl = new URL(calls[0]!.url);
+  assert.equal(queryUrl.pathname, '/parse/classes/VocProduct');
+  assert.deepEqual(JSON.parse(queryUrl.searchParams.get('where')!), { workspaceId: 'demashi', platform: 'jd' });
+  assert.equal(queryUrl.searchParams.get('order'), 'productKey');
+  assert.equal(queryUrl.searchParams.get('limit'), '25');
+  assert.equal((calls[0]!.init.headers as Record<string, string>)['X-Parse-Master-Key'], 'master-secret');
+  assert.equal('X-Parse-Master-Key' in (calls[1]!.init.headers as Record<string, string>), false);
+});
+
+test('Parse REST batch requests stay under the upstream limit and preserve the mount path', async () => {
+  let requestBody: { requests: Array<{ path: string }> } | null = null;
+  const fetchMock: typeof fetch = async (_input, init = {}) => {
+    requestBody = JSON.parse(String(init.body)) as { requests: Array<{ path: string }> };
+    return new Response(JSON.stringify([{ success: { objectId: 'one' } }]), {
+      status: 200,
+      headers: { 'Content-Type': 'application/json' },
+    });
+  };
+  const client = new ParseRestClient({
+    serverUrl: 'https://parse.example.test/custom/parse',
+    appId: 'app-id',
+    masterKey: 'master-secret',
+  }, fetchMock);
+
+  await client.batch([{ method: 'POST', path: '/classes/VocWorkspace', body: { publicId: 'demashi' } }]);
+
+  assert.equal(requestBody!.requests[0]!.path, '/custom/parse/classes/VocWorkspace');
+  await assert.rejects(
+    () => client.batch(Array.from({ length: 51 }, () => ({ method: 'DELETE' as const, path: '/classes/VocProduct/id' }))),
+    /limited to 50/,
+  );
+});
+
+test('Parse REST errors expose status and Parse code without returning raw response text', async () => {
+  const client = new ParseRestClient({
+    serverUrl: 'https://parse.example.test/parse',
+    appId: 'app-id',
+    masterKey: 'master-secret',
+  }, async () => new Response(JSON.stringify({ code: 119, error: 'Permission denied' }), {
+    status: 400,
+    headers: { 'Content-Type': 'application/json' },
+  }));
+
+  await assert.rejects(
+    () => client.find('VocWorkspace'),
+    (error: unknown) => error instanceof ParseRestError
+      && error.status === 400
+      && error.code === 119
+      && error.message === 'Permission denied',
+  );
+});