@lionrapid/cli 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +49 -0
- package/README.md +15 -1
- package/dist/api-client.d.ts +119 -1
- package/dist/api-client.js +136 -2
- package/dist/cli.js +208 -2
- package/dist/commands/push.d.ts +34 -0
- package/dist/commands/push.js +23 -1
- package/dist/commands/status.d.ts +76 -0
- package/dist/commands/status.js +221 -0
- package/dist/job-history.d.ts +97 -0
- package/dist/job-history.js +175 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,55 @@
|
|
|
3
3
|
All notable changes to `@lionrapid/cli`. Format based on
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/); this package follows semver.
|
|
5
5
|
|
|
6
|
+
## Unreleased
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- **`push` reports the jobs it queued, and `lionrapid status` says what became
|
|
11
|
+
of them.** `sync/batch` answers `202 Accepted` — queued, not stored — and the
|
|
12
|
+
CLI discarded that response body at the transport layer (`syncBatch` returned
|
|
13
|
+
`void`). A push whose backend job later failed or dead-lettered was therefore
|
|
14
|
+
indistinguishable from one that worked: `pushed N string(s)` described the
|
|
15
|
+
CLI's own outbox and nothing else. Now:
|
|
16
|
+
|
|
17
|
+
- `syncBatch` returns the server's `BatchSyncJobResponse`, and `push` collects
|
|
18
|
+
every `job_ids` entry (not just the legacy `job_id`, so multi-locale batches
|
|
19
|
+
stay observable);
|
|
20
|
+
- `push` prints those ids with a `lionrapid status <job-id>` hint, and says so
|
|
21
|
+
explicitly when a server returns none;
|
|
22
|
+
- new **`lionrapid status [job-id|list]`**: one job's verdict from
|
|
23
|
+
`GET /sync/jobs/{id}` (status, error, retry history, dead-letter reason), or
|
|
24
|
+
the whole local job log newest-first, falling back to `/sync/queue-health`
|
|
25
|
+
and `/sync/dead-letter-queue` when this machine has nothing recorded.
|
|
26
|
+
|
|
27
|
+
- **Push receipts persist in `~/.lionrapid/jobs.json`.** Job id, project,
|
|
28
|
+
file(s), key count, push time and last-seen status, so `status` still answers
|
|
29
|
+
after the terminal is closed — and after the server's ~1h job retention has
|
|
30
|
+
purged the job. Terminal states are recorded and answered from disk with no
|
|
31
|
+
network call; anything unresolved is re-checked. Deliberately a separate file
|
|
32
|
+
from `credentials` (different lifetime and sensitivity — a corrupt history
|
|
33
|
+
must never cost you your login), 0600, written via temp-file + rename, and
|
|
34
|
+
pruned to 500 entries / 30 days.
|
|
35
|
+
|
|
36
|
+
- **Field-level drift guard on the sync response.** `BATCH_SYNC_RESPONSE_FIELDS`
|
|
37
|
+
plus a type-level assertion make a required server field missing from the
|
|
38
|
+
CLI's `SyncBatchResponse` a `tsc --noEmit` failure — `job_id`/`job_ids` were
|
|
39
|
+
silently dropped for exactly as long as nothing checked. (Guard lives in
|
|
40
|
+
`src/`, since tsconfig excludes tests from typechecking.)
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
|
|
44
|
+
- **A `GET` 404 is no longer retried as "sync already processing".** That
|
|
45
|
+
retry-on-404 heuristic is the enqueue path's; `GET /sync/jobs/{id}` returns the
|
|
46
|
+
same `SYNC_JOB_NOT_FOUND` code for a job that simply does not exist, which made
|
|
47
|
+
`status` back off three times before answering.
|
|
48
|
+
|
|
49
|
+
- **Every request carries `X-LR-Surface: cli`.** The server's surface registry
|
|
50
|
+
tells a customer which integration surfaces are live, but snippet, WordPress
|
|
51
|
+
and the CLI share the same project-wide key and were indistinguishable — so
|
|
52
|
+
each client now tags its requests with which surface it is. Older servers
|
|
53
|
+
ignore the header; no behavior changes.
|
|
54
|
+
|
|
6
55
|
## 0.2.2 — 2026-07-27
|
|
7
56
|
|
|
8
57
|
### Added
|
package/README.md
CHANGED
|
@@ -22,6 +22,7 @@ Phase 1 (file-sync dev-loop) is implemented. Command surface:
|
|
|
22
22
|
| ----------------- | ------ | ------------------------------------------------------------------- |
|
|
23
23
|
| `init` | ✅ | Scaffold a `lionrapid.yml` (detects Flutter/iOS/Android/Rails/JS) |
|
|
24
24
|
| `push` | ✅ | Upload source strings to the backend (server handles MT) |
|
|
25
|
+
| `status [job-id]` | ✅ | What the backend actually did with what `push` queued |
|
|
25
26
|
| `pull [--types]` | ✅ | Download translations into target files; optionally regen types |
|
|
26
27
|
| `validate [--ci]` | ✅ | Check local files vs the backend for missing/orphan/empty keys |
|
|
27
28
|
| `types generate` | ✅ | Generate a typed `.d.ts` from your source files (offline) |
|
|
@@ -99,6 +100,17 @@ Built for CI and AI agents — every networked command is resilient and scriptab
|
|
|
99
100
|
zero the run — the rest complete, failures are listed per file (and as
|
|
100
101
|
`error` fields under `--json`), and the exit code is `5` (partial).
|
|
101
102
|
Re-running retries just the failures; pushes upsert, so that's always safe.
|
|
103
|
+
- **Pushed ≠ stored (`push` → `status`)**: `sync/batch` answers `202 Accepted`,
|
|
104
|
+
so `push`'s counts are what the CLI **sent**; the backend stores them in a
|
|
105
|
+
background job that can still fail or dead-letter. `push` therefore prints the
|
|
106
|
+
job ids it queued and saves them to `~/.lionrapid/jobs.json`, and
|
|
107
|
+
`lionrapid status <job-id>` reports the server's verdict — stored, still
|
|
108
|
+
queued, retrying, or dead-lettered (with the reason and every failed attempt).
|
|
109
|
+
Bare `lionrapid status` lists every job this machine pushed, newest first,
|
|
110
|
+
re-checking only the unresolved ones; with nothing recorded it falls back to
|
|
111
|
+
the server's queue health + dead-letter queue. Offline, it still prints the
|
|
112
|
+
local record, labelled `(last known)`. History is capped at 500 entries /
|
|
113
|
+
30 days and never shares a file with your credentials.
|
|
102
114
|
- **Concurrent `pull`**: namespace fetches fan out with bounded concurrency
|
|
103
115
|
(`LIONRAPID_CONCURRENCY`, default `6`) and show interactive progress on a TTY.
|
|
104
116
|
Output stays deterministic — only the network fan-out is parallelized.
|
|
@@ -178,7 +190,9 @@ already translated on your website are translated in the app for free.
|
|
|
178
190
|
|
|
179
191
|
```bash
|
|
180
192
|
lionrapid init --project <id> --targets es,fr
|
|
181
|
-
lionrapid push
|
|
193
|
+
lionrapid push # prints the job ids it queued
|
|
194
|
+
lionrapid status # …and what the backend did with them
|
|
195
|
+
lionrapid status <job-id> # the full story on one job
|
|
182
196
|
lionrapid pull --types
|
|
183
197
|
lionrapid validate --ci
|
|
184
198
|
|
package/dist/api-client.d.ts
CHANGED
|
@@ -14,6 +14,90 @@ export interface KeyedTranslation {
|
|
|
14
14
|
key: string;
|
|
15
15
|
value: ContentUnit;
|
|
16
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* The 202 body of `POST /api/integrations/sync/batch` — a mirror of the
|
|
19
|
+
* server's `BatchSyncJobResponse` (tm-server
|
|
20
|
+
* `backend/app/domains/integrations/contracts/responses.py`). Field names are
|
|
21
|
+
* the wire names on purpose: this type IS the contract, and
|
|
22
|
+
* {@link BATCH_SYNC_RESPONSE_FIELDS} guards it against server drift.
|
|
23
|
+
*
|
|
24
|
+
* 202 means **queued, not stored**. The rows only reach the database when the
|
|
25
|
+
* job queued here actually runs, so a push that returns 202 for every batch is
|
|
26
|
+
* still compatible with the backend later failing or dead-lettering the work.
|
|
27
|
+
* The job ids are the only way a client can tell those apart — discarding this
|
|
28
|
+
* body (as `syncBatch` used to) is what made a failed sync indistinguishable
|
|
29
|
+
* from a successful one.
|
|
30
|
+
*/
|
|
31
|
+
export interface SyncBatchResponse {
|
|
32
|
+
/** First queued job id — the server's legacy alias for `job_ids[0]`. */
|
|
33
|
+
job_id: string;
|
|
34
|
+
/** ALL queued job ids — one per locale group in the batch. */
|
|
35
|
+
job_ids: string[];
|
|
36
|
+
/** Job status at enqueue time (`pending`). */
|
|
37
|
+
status: string;
|
|
38
|
+
/** Number of keys the server accepted into the batch. */
|
|
39
|
+
total_keys: number;
|
|
40
|
+
/** Human-readable message — carries the server's own poll hint. */
|
|
41
|
+
message: string;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The REQUIRED fields of the server's `BatchSyncJobResponse`, transcribed from
|
|
45
|
+
* `contracts/responses.py` (a field with no default is required there).
|
|
46
|
+
*
|
|
47
|
+
* DRIFT GUARD, field level. `job_id`/`job_ids` existed server-side the whole
|
|
48
|
+
* time and the CLI silently dropped them — a `Promise<void>` cannot be caught
|
|
49
|
+
* by any test, because there is nothing to assert on. This list plus
|
|
50
|
+
* {@link SyncBatchResponseCoversContract} makes that failure a *compile* error:
|
|
51
|
+
* add a required field here without adding it to {@link SyncBatchResponse} and
|
|
52
|
+
* `npm run typecheck` fails, so the next server field can't vanish the same way.
|
|
53
|
+
*
|
|
54
|
+
* (Deliberately not derived from the server's OpenAPI: tm-server is a separate
|
|
55
|
+
* repo, so that check would need a network fetch or a snapshot to drift on its
|
|
56
|
+
* own. Hand-transcribing five names is the cheaper, non-flaky half.)
|
|
57
|
+
*/
|
|
58
|
+
export declare const BATCH_SYNC_RESPONSE_FIELDS: readonly ["job_id", "job_ids", "status", "total_keys", "message"];
|
|
59
|
+
/** Compiles only when `T` is `never` — i.e. no contract field is unaccounted for. */
|
|
60
|
+
type NoMissingContractFields<T extends never> = T;
|
|
61
|
+
/**
|
|
62
|
+
* The guard itself. Exported as a type (not a value) so it costs no runtime and
|
|
63
|
+
* no lint suppression, and it is checked because `tsc --noEmit` covers `src/`.
|
|
64
|
+
*/
|
|
65
|
+
export type SyncBatchResponseCoversContract = NoMissingContractFields<Exclude<(typeof BATCH_SYNC_RESPONSE_FIELDS)[number], keyof SyncBatchResponse>>;
|
|
66
|
+
/**
|
|
67
|
+
* A sync job's server-side state — `GET /api/integrations/sync/jobs/{id}`
|
|
68
|
+
* (`SyncJobStatusResponse`). Camel-cased because, unlike
|
|
69
|
+
* {@link SyncBatchResponse}, this is the CLI's own view rather than a guarded
|
|
70
|
+
* contract mirror.
|
|
71
|
+
*
|
|
72
|
+
* `status` is one of `pending` / `processing` / `retry_pending` / `completed` /
|
|
73
|
+
* `failed`. The server only ever sets `failed` from its dead-letter path, so a
|
|
74
|
+
* `failed` job is one the backend gave up on: those rows were never stored.
|
|
75
|
+
*/
|
|
76
|
+
export interface SyncJobStatus {
|
|
77
|
+
jobId: string;
|
|
78
|
+
status: string;
|
|
79
|
+
createdAt?: string;
|
|
80
|
+
startedAt?: string;
|
|
81
|
+
completedAt?: string;
|
|
82
|
+
/** Sync outcome on success (`keys_added`, `keys_updated`, …). */
|
|
83
|
+
result?: Record<string, unknown>;
|
|
84
|
+
error?: string;
|
|
85
|
+
retryCount: number;
|
|
86
|
+
maxRetries: number;
|
|
87
|
+
nextRetryAt?: string;
|
|
88
|
+
/** One entry per failed attempt, oldest first. */
|
|
89
|
+
retryErrors: string[];
|
|
90
|
+
isDeadLettered: boolean;
|
|
91
|
+
deadLetterReason?: string;
|
|
92
|
+
}
|
|
93
|
+
/** Queue-wide metrics — `GET /api/integrations/sync/queue-health`. */
|
|
94
|
+
export interface QueueHealth {
|
|
95
|
+
totalJobs: number;
|
|
96
|
+
queueDepth: number;
|
|
97
|
+
statusCounts: Record<string, number>;
|
|
98
|
+
dlqCount: number;
|
|
99
|
+
workerRunning: boolean;
|
|
100
|
+
}
|
|
17
101
|
/** Result of a namespace pull. `notModified` is true on a 304 (ETag hit). */
|
|
18
102
|
export interface NamespaceBundle {
|
|
19
103
|
translations: Record<string, ContentUnit>;
|
|
@@ -89,6 +173,18 @@ export declare class NetworkError extends Error {
|
|
|
89
173
|
* is dropped with a warning rather than crashing the run.
|
|
90
174
|
*/
|
|
91
175
|
export declare function normalizeTranslations(raw: unknown): Record<string, ContentUnit>;
|
|
176
|
+
/**
|
|
177
|
+
* Project the sync/batch body onto {@link SyncBatchResponse}, field by field.
|
|
178
|
+
*
|
|
179
|
+
* Written out explicitly rather than cast, so a dropped field is a *runtime*
|
|
180
|
+
* regression a test can see as well as a compile error (see
|
|
181
|
+
* {@link BATCH_SYNC_RESPONSE_FIELDS}). Every field degrades instead of throwing:
|
|
182
|
+
* an older server, a proxy that eats the body, or a 204 must leave push working
|
|
183
|
+
* — you simply get no job ids to report, which the CLI says out loud.
|
|
184
|
+
*/
|
|
185
|
+
export declare function normalizeSyncBatchResponse(raw: unknown): SyncBatchResponse;
|
|
186
|
+
/** Project a `SyncJobStatusResponse` (job status, or a DLQ entry) onto {@link SyncJobStatus}. */
|
|
187
|
+
export declare function normalizeSyncJob(raw: unknown): SyncJobStatus;
|
|
92
188
|
export declare class LionRapidApiClient {
|
|
93
189
|
private readonly baseUrl;
|
|
94
190
|
private readonly apiKey;
|
|
@@ -117,8 +213,29 @@ export declare class LionRapidApiClient {
|
|
|
117
213
|
/**
|
|
118
214
|
* Register source-row text for `sourceLocale`. Mirrors what the edge does on
|
|
119
215
|
* first visit when it encounters a missing key. POST /api/integrations/sync/batch.
|
|
216
|
+
*
|
|
217
|
+
* Returns the server's 202 body. It used to return `void` — the response was
|
|
218
|
+
* read and thrown away at the transport layer, which is why a push whose jobs
|
|
219
|
+
* later died looked exactly like one that worked. Callers get the job ids and
|
|
220
|
+
* can poll {@link getSyncJob}.
|
|
221
|
+
*/
|
|
222
|
+
syncBatch(sourceLocale: string, namespace: string, items: KeyedTranslation[]): Promise<SyncBatchResponse>;
|
|
223
|
+
/**
|
|
224
|
+
* A queued sync job's state. GET /api/integrations/sync/jobs/{id}.
|
|
225
|
+
*
|
|
226
|
+
* The lookup is project-scoped server-side, and finished non-DLQ jobs are
|
|
227
|
+
* purged after ~1h — an unknown id 404s, which is why the CLI also keeps its
|
|
228
|
+
* own history (a job that completed yesterday can only be answered from disk).
|
|
229
|
+
*/
|
|
230
|
+
getSyncJob(jobId: string): Promise<SyncJobStatus>;
|
|
231
|
+
/**
|
|
232
|
+
* Jobs the backend gave up on. GET /api/integrations/sync/dead-letter-queue.
|
|
233
|
+
* Needs the `ops:queue` scope (master keys have it; publishable `pk_` keys
|
|
234
|
+
* do not — those get a 403).
|
|
120
235
|
*/
|
|
121
|
-
|
|
236
|
+
getDeadLetterQueue(): Promise<SyncJobStatus[]>;
|
|
237
|
+
/** Queue-wide metrics. GET /api/integrations/sync/queue-health (`ops:queue`). */
|
|
238
|
+
getQueueHealth(): Promise<QueueHealth>;
|
|
122
239
|
/**
|
|
123
240
|
* Write target-locale translations as drafts (editor-visible, not public
|
|
124
241
|
* until mark-ready). PUT /api/integrations/targets/batch.
|
|
@@ -152,4 +269,5 @@ export declare class LionRapidApiClient {
|
|
|
152
269
|
*/
|
|
153
270
|
private doFetch;
|
|
154
271
|
}
|
|
272
|
+
export {};
|
|
155
273
|
//# sourceMappingURL=api-client.d.ts.map
|
package/dist/api-client.js
CHANGED
|
@@ -8,6 +8,28 @@
|
|
|
8
8
|
* uses native `fetch`. `fetch` is injectable so the client is unit-testable
|
|
9
9
|
* without a live server.
|
|
10
10
|
*/
|
|
11
|
+
/**
|
|
12
|
+
* The REQUIRED fields of the server's `BatchSyncJobResponse`, transcribed from
|
|
13
|
+
* `contracts/responses.py` (a field with no default is required there).
|
|
14
|
+
*
|
|
15
|
+
* DRIFT GUARD, field level. `job_id`/`job_ids` existed server-side the whole
|
|
16
|
+
* time and the CLI silently dropped them — a `Promise<void>` cannot be caught
|
|
17
|
+
* by any test, because there is nothing to assert on. This list plus
|
|
18
|
+
* {@link SyncBatchResponseCoversContract} makes that failure a *compile* error:
|
|
19
|
+
* add a required field here without adding it to {@link SyncBatchResponse} and
|
|
20
|
+
* `npm run typecheck` fails, so the next server field can't vanish the same way.
|
|
21
|
+
*
|
|
22
|
+
* (Deliberately not derived from the server's OpenAPI: tm-server is a separate
|
|
23
|
+
* repo, so that check would need a network fetch or a snapshot to drift on its
|
|
24
|
+
* own. Hand-transcribing five names is the cheaper, non-flaky half.)
|
|
25
|
+
*/
|
|
26
|
+
export const BATCH_SYNC_RESPONSE_FIELDS = [
|
|
27
|
+
'job_id',
|
|
28
|
+
'job_ids',
|
|
29
|
+
'status',
|
|
30
|
+
'total_keys',
|
|
31
|
+
'message',
|
|
32
|
+
];
|
|
11
33
|
/** Default number of retries for transient failures. */
|
|
12
34
|
export const DEFAULT_RETRIES = 3;
|
|
13
35
|
/** Default per-attempt request timeout. */
|
|
@@ -25,6 +47,13 @@ const MAX_RETRY_AFTER_MS = 30_000;
|
|
|
25
47
|
* 30/min sync limit died at namespace 31).
|
|
26
48
|
*/
|
|
27
49
|
const RATE_LIMIT_FALLBACK_MS = 30_000;
|
|
50
|
+
/**
|
|
51
|
+
* Surface self-identification for the server's surface registry: snippet,
|
|
52
|
+
* WordPress and the CLI all share the same project-wide key, so each client
|
|
53
|
+
* tags its requests with which surface it is. Older servers ignore unknown
|
|
54
|
+
* headers — additive and inert.
|
|
55
|
+
*/
|
|
56
|
+
const SURFACE_HEADER = { 'X-LR-Surface': 'cli' };
|
|
28
57
|
/** Transient statuses worth retrying: rate-limit (429) and any 5xx. */
|
|
29
58
|
function isRetryableStatus(status) {
|
|
30
59
|
return status === 429 || status >= 500;
|
|
@@ -151,6 +180,58 @@ export function normalizeTranslations(raw) {
|
|
|
151
180
|
walk(raw, '');
|
|
152
181
|
return out;
|
|
153
182
|
}
|
|
183
|
+
function isRecord(value) {
|
|
184
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
185
|
+
}
|
|
186
|
+
function asString(value) {
|
|
187
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
188
|
+
}
|
|
189
|
+
function asStringArray(value) {
|
|
190
|
+
return Array.isArray(value) ? value.filter((v) => typeof v === 'string') : [];
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Project the sync/batch body onto {@link SyncBatchResponse}, field by field.
|
|
194
|
+
*
|
|
195
|
+
* Written out explicitly rather than cast, so a dropped field is a *runtime*
|
|
196
|
+
* regression a test can see as well as a compile error (see
|
|
197
|
+
* {@link BATCH_SYNC_RESPONSE_FIELDS}). Every field degrades instead of throwing:
|
|
198
|
+
* an older server, a proxy that eats the body, or a 204 must leave push working
|
|
199
|
+
* — you simply get no job ids to report, which the CLI says out loud.
|
|
200
|
+
*/
|
|
201
|
+
export function normalizeSyncBatchResponse(raw) {
|
|
202
|
+
const body = isRecord(raw) ? raw : {};
|
|
203
|
+
const primary = asString(body['job_id']);
|
|
204
|
+
const ids = asStringArray(body['job_ids']);
|
|
205
|
+
// The server sets job_id = job_ids[0]; tolerate either side being absent so
|
|
206
|
+
// one missing field never costs us the other.
|
|
207
|
+
const jobIds = ids.length > 0 ? ids : primary ? [primary] : [];
|
|
208
|
+
return {
|
|
209
|
+
job_id: primary ?? jobIds[0] ?? '',
|
|
210
|
+
job_ids: jobIds,
|
|
211
|
+
status: asString(body['status']) ?? '',
|
|
212
|
+
total_keys: typeof body['total_keys'] === 'number' ? body['total_keys'] : 0,
|
|
213
|
+
message: asString(body['message']) ?? '',
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
/** Project a `SyncJobStatusResponse` (job status, or a DLQ entry) onto {@link SyncJobStatus}. */
|
|
217
|
+
export function normalizeSyncJob(raw) {
|
|
218
|
+
const body = isRecord(raw) ? raw : {};
|
|
219
|
+
return {
|
|
220
|
+
jobId: asString(body['job_id']) ?? '',
|
|
221
|
+
status: asString(body['status']) ?? 'unknown',
|
|
222
|
+
createdAt: asString(body['created_at']),
|
|
223
|
+
startedAt: asString(body['started_at']),
|
|
224
|
+
completedAt: asString(body['completed_at']),
|
|
225
|
+
result: isRecord(body['result']) ? body['result'] : undefined,
|
|
226
|
+
error: asString(body['error']),
|
|
227
|
+
retryCount: typeof body['retry_count'] === 'number' ? body['retry_count'] : 0,
|
|
228
|
+
maxRetries: typeof body['max_retries'] === 'number' ? body['max_retries'] : 0,
|
|
229
|
+
nextRetryAt: asString(body['next_retry_at']),
|
|
230
|
+
retryErrors: asStringArray(body['retry_errors']),
|
|
231
|
+
isDeadLettered: body['is_dead_lettered'] === true,
|
|
232
|
+
deadLetterReason: asString(body['dead_letter_reason']),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
154
235
|
export class LionRapidApiClient {
|
|
155
236
|
baseUrl;
|
|
156
237
|
apiKey;
|
|
@@ -196,16 +277,62 @@ export class LionRapidApiClient {
|
|
|
196
277
|
/**
|
|
197
278
|
* Register source-row text for `sourceLocale`. Mirrors what the edge does on
|
|
198
279
|
* first visit when it encounters a missing key. POST /api/integrations/sync/batch.
|
|
280
|
+
*
|
|
281
|
+
* Returns the server's 202 body. It used to return `void` — the response was
|
|
282
|
+
* read and thrown away at the transport layer, which is why a push whose jobs
|
|
283
|
+
* later died looked exactly like one that worked. Callers get the job ids and
|
|
284
|
+
* can poll {@link getSyncJob}.
|
|
199
285
|
*/
|
|
200
286
|
async syncBatch(sourceLocale, namespace, items) {
|
|
201
287
|
const translations = items.map((it) => ({
|
|
202
288
|
key: compositeKey(sourceLocale, namespace, it.key),
|
|
203
289
|
value: it.value,
|
|
204
290
|
}));
|
|
205
|
-
await this.request('POST', '/api/integrations/sync/batch', {
|
|
291
|
+
const res = await this.request('POST', '/api/integrations/sync/batch', {
|
|
206
292
|
translations,
|
|
207
293
|
format: 'contentunit',
|
|
208
294
|
});
|
|
295
|
+
return normalizeSyncBatchResponse(res);
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* A queued sync job's state. GET /api/integrations/sync/jobs/{id}.
|
|
299
|
+
*
|
|
300
|
+
* The lookup is project-scoped server-side, and finished non-DLQ jobs are
|
|
301
|
+
* purged after ~1h — an unknown id 404s, which is why the CLI also keeps its
|
|
302
|
+
* own history (a job that completed yesterday can only be answered from disk).
|
|
303
|
+
*/
|
|
304
|
+
async getSyncJob(jobId) {
|
|
305
|
+
const res = await this.request('GET', `/api/integrations/sync/jobs/${encodeURIComponent(jobId)}`);
|
|
306
|
+
return normalizeSyncJob(res);
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Jobs the backend gave up on. GET /api/integrations/sync/dead-letter-queue.
|
|
310
|
+
* Needs the `ops:queue` scope (master keys have it; publishable `pk_` keys
|
|
311
|
+
* do not — those get a 403).
|
|
312
|
+
*/
|
|
313
|
+
async getDeadLetterQueue() {
|
|
314
|
+
const res = await this.request('GET', '/api/integrations/sync/dead-letter-queue');
|
|
315
|
+
const jobs = isRecord(res) && Array.isArray(res['jobs']) ? res['jobs'] : [];
|
|
316
|
+
return jobs.map(normalizeSyncJob);
|
|
317
|
+
}
|
|
318
|
+
/** Queue-wide metrics. GET /api/integrations/sync/queue-health (`ops:queue`). */
|
|
319
|
+
async getQueueHealth() {
|
|
320
|
+
const res = await this.request('GET', '/api/integrations/sync/queue-health');
|
|
321
|
+
const body = isRecord(res) ? res : {};
|
|
322
|
+
const counts = {};
|
|
323
|
+
if (isRecord(body['status_counts'])) {
|
|
324
|
+
for (const [k, v] of Object.entries(body['status_counts'])) {
|
|
325
|
+
if (typeof v === 'number')
|
|
326
|
+
counts[k] = v;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return {
|
|
330
|
+
totalJobs: typeof body['total_jobs'] === 'number' ? body['total_jobs'] : 0,
|
|
331
|
+
queueDepth: typeof body['queue_depth'] === 'number' ? body['queue_depth'] : 0,
|
|
332
|
+
statusCounts: counts,
|
|
333
|
+
dlqCount: typeof body['dlq_count'] === 'number' ? body['dlq_count'] : 0,
|
|
334
|
+
workerRunning: body['worker_running'] === true,
|
|
335
|
+
};
|
|
209
336
|
}
|
|
210
337
|
/**
|
|
211
338
|
* Write target-locale translations as drafts (editor-visible, not public
|
|
@@ -305,6 +432,7 @@ export class LionRapidApiClient {
|
|
|
305
432
|
// is per-attempt, so every retry gets a fresh budget.
|
|
306
433
|
res = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
307
434
|
...init,
|
|
435
|
+
headers: { ...SURFACE_HEADER, ...init.headers },
|
|
308
436
|
redirect: 'error',
|
|
309
437
|
signal: AbortSignal.timeout(this.timeoutMs),
|
|
310
438
|
});
|
|
@@ -321,7 +449,13 @@ export class LionRapidApiClient {
|
|
|
321
449
|
// Exception: a 404 that's really "a sync is already processing for this
|
|
322
450
|
// project" is transient. Peek at the body (only for 404s); retry if it's
|
|
323
451
|
// that case and attempts remain, otherwise hand the body back intact.
|
|
324
|
-
|
|
452
|
+
//
|
|
453
|
+
// Writes only: that 404 is the enqueue path's answer. `GET
|
|
454
|
+
// /sync/jobs/{id}` returns the same SYNC_JOB_NOT_FOUND code for a job
|
|
455
|
+
// that genuinely does not exist (unknown id, or one purged after the
|
|
456
|
+
// server's ~1h retention), and `lionrapid status` must answer that
|
|
457
|
+
// immediately instead of backing off three times first.
|
|
458
|
+
if (res.status === 404 && method !== 'GET' && attempt < this.retries) {
|
|
325
459
|
const body = await safeText(res);
|
|
326
460
|
if (isSyncInProgress(body)) {
|
|
327
461
|
await this.sleep(backoffMs(attempt, Math.random));
|