소스 검색

feat: add Parse REST VOC persistence

gangvy 1 개월 전
부모
커밋
f6f7cf3a12

+ 2 - 0
.env.example

@@ -1,4 +1,5 @@
 NODE_ENV=development
 NODE_ENV=development
+STORAGE_DRIVER=parse_rest
 LOCAL_HOST=127.0.0.1
 LOCAL_HOST=127.0.0.1
 LOCAL_PORT=4400
 LOCAL_PORT=4400
 LOCAL_WORKSPACE_ID=demashi
 LOCAL_WORKSPACE_ID=demashi
@@ -14,6 +15,7 @@ PARSE_APP_ID=
 PARSE_MASTER_KEY=
 PARSE_MASTER_KEY=
 PARSE_MAINTENANCE_KEY=
 PARSE_MAINTENANCE_KEY=
 PARSE_SERVER_URL=http://127.0.0.1:4400/parse
 PARSE_SERVER_URL=http://127.0.0.1:4400/parse
+PARSE_REST_TIMEOUT_MS=30000
 API_AUTH_MODE=parse
 API_AUTH_MODE=parse
 LOCAL_AUTH_USER_ID=local-admin
 LOCAL_AUTH_USER_ID=local-admin
 LOCAL_AUTH_USER_EMAIL=local-admin@localhost
 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
 ## Current state
 
 
-Completed on 2026-07-23:
+Completed through 2026-07-24:
 
 
 - Independent Git repository and Node.js 22 / TypeScript service baseline.
 - 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.
 - 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.
 - Configurable local workspace identity instead of a Demashi-only runtime branch.
 - Manual sync retry/cancel operations plus automatic recovery for stale processing jobs.
 - 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.
 - 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.
 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
       -> shared API and authorization contract
           -> local in-memory repository (development)
           -> local in-memory repository (development)
           -> dedicated PostgreSQL repository (production, voc schema)
           -> dedicated PostgreSQL repository (production, voc schema)
+          -> external Parse REST repository (shared development or managed Parse)
       -> dedicated Parse application (production authentication)
       -> dedicated Parse application (production authentication)
       -> sync queue
       -> sync queue
           -> existing /api/voc-e-commerce gateway
           -> 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.
 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
 ### 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
 ### 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.
 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.
 - Action assignees must be active members of the same workspace.
 - Member, sync, analysis, action, and alert writes append an audit entry.
 - 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.
 - 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
 ## Infrastructure handoff
 
 
@@ -211,18 +232,21 @@ When the dedicated database and server are available, no route or frontend contr
 ```powershell
 ```powershell
 npm run build
 npm run build
 npm test
 npm test
+npm run verify:parse-rest -- demashi jd
 npm audit --omit=dev
 npm audit --omit=dev
 ```
 ```
 
 
 Current result:
 Current result:
 
 
 - TypeScript build: passed.
 - 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.
 - Production dependency audit: 0 critical, 0 high, 13 moderate.
 - Company gateway health: HTTP 200 on 2026-07-23.
 - 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 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.
 - 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.
 - 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.
 See `TASKS.md` for the implementation sequence and acceptance boundary.

+ 7 - 5
TASKS.md

@@ -1,6 +1,6 @@
 # Backend implementation tasks
 # Backend implementation tasks
 
 
-Status date: 2026-07-23
+Status date: 2026-07-24
 
 
 ## Phase 1 - isolated backend baseline
 ## 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.
 - [x] Protect owner membership and validate action assignees inside the workspace boundary.
 - [ ] Run migrations against a newly provisioned PostgreSQL database.
 - [ ] Run migrations against a newly provisioned PostgreSQL database.
 - [x] Smoke-test the local HTTP service under the supported Node 22 runtime.
 - [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
 ## Phase 2 - case-data persistence
 
 
 - [x] Add a batch importer for the normalized Demashi dataset.
 - [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.
 - [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
 ## 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": "node dist/src/server.js",
     "start:local": "tsx src/local-server.ts",
     "start:local": "tsx src/local-server.ts",
     "migrate": "tsx scripts/migrate.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",
     "import:dataset": "tsx scripts/import-dataset.ts",
     "test": "tsx --test test/**/*.test.ts",
     "test": "tsx --test test/**/*.test.ts",
     "test:coverage": "tsx --test --experimental-test-coverage 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 { ZodError } from 'zod';
 import type { AppConfig } from './config/env.js';
 import type { AppConfig } from './config/env.js';
 import { ApiError } from './http/api-error.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 { SnapshotService } from './modules/domestic-voc/services/snapshot.service.js';
 import { SyncService } from './modules/domestic-voc/services/sync.service.js';
 import { SyncService } from './modules/domestic-voc/services/sync.service.js';
 import { createAuthenticationMiddleware, createAuthenticator, WorkspaceAccessService } from './modules/saas-platform/auth.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: {
 export function createApp(input: {
   config: AppConfig;
   config: AppConfig;
-  pool: Pool;
+  pool?: Pool;
   parseApp?: RequestHandler;
   parseApp?: RequestHandler;
   platformRepository?: PlatformRepository;
   platformRepository?: PlatformRepository;
+  jobs?: SyncJobStore;
+  snapshot?: DomesticSnapshotProvider;
+  healthCheck?: () => Promise<{ ready: boolean; missingObjects: string[] }>;
 }) {
 }) {
   const app = express();
   const app = express();
   app.disable('x-powered-by');
   app.disable('x-powered-by');
@@ -32,6 +35,29 @@ export function createApp(input: {
 
 
   app.get('/health', async (_request, response) => {
   app.get('/health', async (_request, response) => {
     try {
     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>>(`
       const result = await input.pool.query<Record<string, string | null>>(`
         SELECT
         SELECT
           to_regclass('voc.workspace')::text AS workspace,
           to_regclass('voc.workspace')::text AS workspace,
@@ -76,13 +102,14 @@ export function createApp(input: {
 
 
   if (input.parseApp) app.use('/parse', input.parseApp);
   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);
   const access = new WorkspaceAccessService(platform);
   app.use('/api', createAuthenticationMiddleware(createAuthenticator(input.config)));
   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 sync = new SyncService(jobs);
-  const snapshot = new SnapshotService(input.pool);
+  const snapshot = input.snapshot ?? new SnapshotService(input.pool!);
   app.use('/api/domestic-voc', createDomesticVocRouter({
   app.use('/api/domestic-voc', createDomesticVocRouter({
     jobs,
     jobs,
     sync,
     sync,

+ 25 - 7
src/config/env.ts

@@ -7,17 +7,19 @@ const optionalNonEmptyString = z.preprocess(
 
 
 const environmentSchema = z.object({
 const environmentSchema = z.object({
   NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
   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'),
   HOST: z.string().min(1).default('127.0.0.1'),
   PORT: z.coerce.number().int().min(1).max(65535).default(4400),
   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,
   MIGRATION_DATABASE_URL: optionalNonEmptyString,
   DATABASE_POOL_MAX: z.coerce.number().int().min(1).max(50).default(10),
   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_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),
   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_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'),
   API_AUTH_MODE: z.enum(['disabled', 'parse']).default('parse'),
   LOCAL_AUTH_USER_ID: z.string().min(1).default('local-admin'),
   LOCAL_AUTH_USER_ID: z.string().min(1).default('local-admin'),
   LOCAL_AUTH_USER_EMAIL: z.string().default('local-admin@localhost'),
   LOCAL_AUTH_USER_EMAIL: z.string().default('local-admin@localhost'),
@@ -41,6 +43,7 @@ export type AppConfig = {
   nodeEnv: 'development' | 'test' | 'production';
   nodeEnv: 'development' | 'test' | 'production';
   host: string;
   host: string;
   port: number;
   port: number;
+  storageDriver: 'postgres' | 'parse_rest';
   database: {
   database: {
     url: string;
     url: string;
     migrationUrl: string;
     migrationUrl: string;
@@ -53,6 +56,7 @@ export type AppConfig = {
     masterKey: string;
     masterKey: string;
     maintenanceKey: string;
     maintenanceKey: string;
     serverUrl: string;
     serverUrl: string;
+    timeoutMs: number;
   };
   };
   auth: {
   auth: {
     mode: 'disabled' | 'parse';
     mode: 'disabled' | 'parse';
@@ -93,10 +97,22 @@ export function loadConfig(environment: NodeJS.ProcessEnv = process.env): AppCon
       const field = issue.path.join('.') || 'environment';
       const field = issue.path.join('.') || 'environment';
       return `${field}: ${issue.message}`;
       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);
     throw new EnvironmentConfigurationError(issues);
   }
   }
 
 
   const value = result.data;
   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') {
   if (value.NODE_ENV === 'production' && value.API_AUTH_MODE === 'disabled') {
     throw new EnvironmentConfigurationError([
     throw new EnvironmentConfigurationError([
       'API_AUTH_MODE: disabled authentication is not allowed in production',
       '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,
     nodeEnv: value.NODE_ENV,
     host: value.HOST,
     host: value.HOST,
     port: value.PORT,
     port: value.PORT,
+    storageDriver: value.STORAGE_DRIVER,
     database: {
     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,
       poolMax: value.DATABASE_POOL_MAX,
       idleTimeoutMs: value.DATABASE_IDLE_TIMEOUT_MS,
       idleTimeoutMs: value.DATABASE_IDLE_TIMEOUT_MS,
       statementTimeoutMs: value.DATABASE_STATEMENT_TIMEOUT_MS,
       statementTimeoutMs: value.DATABASE_STATEMENT_TIMEOUT_MS,
@@ -116,8 +133,9 @@ export function loadConfig(environment: NodeJS.ProcessEnv = process.env): AppCon
     parse: {
     parse: {
       appId: value.PARSE_APP_ID,
       appId: value.PARSE_APP_ID,
       masterKey: value.PARSE_MASTER_KEY,
       masterKey: value.PARSE_MASTER_KEY,
-      maintenanceKey: value.PARSE_MAINTENANCE_KEY,
+      maintenanceKey: value.PARSE_MAINTENANCE_KEY ?? '',
       serverUrl: value.PARSE_SERVER_URL,
       serverUrl: value.PARSE_SERVER_URL,
+      timeoutMs: value.PARSE_REST_TIMEOUT_MS,
     },
     },
     auth: {
     auth: {
       mode: value.API_AUTH_MODE,
       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 {
 export class ParseSessionAuthenticator implements RequestAuthenticator {
   constructor(
   constructor(
-    private readonly parse: AppConfig['parse'],
+    private readonly parse: Pick<AppConfig['parse'], 'appId' | 'serverUrl'>,
     private readonly fetchImplementation: typeof fetch = globalThis.fetch,
     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 { loadConfig } from './config/env.js';
 import { createParseServer } from './config/parse.js';
 import { createParseServer } from './config/parse.js';
 import { createDatabasePool } from './db/pool.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 { startSyncWorker } from './modules/domestic-voc/jobs/sync-worker.js';
 import { VocIngestionRepository } from './modules/domestic-voc/repositories/voc-ingestion.repository.js';
 import { VocIngestionRepository } from './modules/domestic-voc/repositories/voc-ingestion.repository.js';
 import { JdSyncService } from './modules/domestic-voc/services/jd-sync.service.js';
 import { JdSyncService } from './modules/domestic-voc/services/jd-sync.service.js';
 import { FmodeVocEcommerceClient } from './modules/domestic-voc/upstream/fmode-client.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';
 import { PostgresPlatformRepository } from './modules/saas-platform/postgres-platform.repository.js';
 
 
 async function main(): Promise<void> {
 async function main(): Promise<void> {
   const config = loadConfig();
   const config = loadConfig();
-  const pool = createDatabasePool(config);
-  const parseServer = await createParseServer(config);
-  const platformRepository = new PostgresPlatformRepository(pool);
   const bootstrapUserId = config.auth.bootstrapAdminUserId
   const bootstrapUserId = config.auth.bootstrapAdminUserId
     || (config.auth.mode === 'disabled' ? config.auth.localUserId : '');
     || (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 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, () => {
   server.listen(config.port, config.host, () => {
     console.log(`[server] listening on http://${config.host}:${config.port}`);
     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`);
     console.log(`[server] received ${signal}; shutting down`);
     await worker?.stop();
     await worker?.stop();
     server.close(async () => {
     server.close(async () => {
-      await parseServer.handleShutdown();
-      await pool.end();
+      await closeStorage();
       process.exit(0);
       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, '');
   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', () => {
 test('loadConfig rejects disabled authentication in production', () => {
   assert.throws(
   assert.throws(
     () => loadConfig({ ...validEnvironment, NODE_ENV: 'production', API_AUTH_MODE: 'disabled' }),
     () => 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',
+  );
+});