@rightkit/social-ui 0.0.0-stage → 0.1.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 +5 -0
- package/LICENSE-APACHE +201 -0
- package/LICENSE-MIT +21 -0
- package/README.md +40 -2
- package/dist/appName.d.ts +2 -0
- package/dist/appName.js +8 -0
- package/dist/components/ScopeSelect.d.ts +7 -0
- package/dist/components/ScopeSelect.js +6 -0
- package/dist/components/Shared.d.ts +34 -0
- package/dist/components/Shared.js +55 -0
- package/dist/data/derive.d.ts +21 -0
- package/dist/data/derive.js +47 -0
- package/dist/data/draftRules.d.ts +10 -0
- package/dist/data/draftRules.js +20 -0
- package/dist/data/engine/engineDataSource.d.ts +194 -0
- package/dist/data/engine/engineDataSource.js +836 -0
- package/dist/data/engine/errors.d.ts +13 -0
- package/dist/data/engine/errors.js +21 -0
- package/dist/data/engine/mapping.d.ts +172 -0
- package/dist/data/engine/mapping.js +1487 -0
- package/dist/data/engine/protocol.d.ts +910 -0
- package/dist/data/engine/protocol.js +67 -0
- package/dist/data/index.d.ts +18 -0
- package/dist/data/index.js +23 -0
- package/dist/data/mock/devSource.d.ts +7 -0
- package/dist/data/mock/devSource.js +10 -0
- package/dist/data/mock/fixtures-analytics.d.ts +7 -0
- package/dist/data/mock/fixtures-analytics.js +123 -0
- package/dist/data/mock/fixtures-briefs.d.ts +4 -0
- package/dist/data/mock/fixtures-briefs.js +230 -0
- package/dist/data/mock/fixtures-content.d.ts +4 -0
- package/dist/data/mock/fixtures-content.js +89 -0
- package/dist/data/mock/fixtures-digests.d.ts +3 -0
- package/dist/data/mock/fixtures-digests.js +40 -0
- package/dist/data/mock/fixtures-drafts.d.ts +2 -0
- package/dist/data/mock/fixtures-drafts.js +74 -0
- package/dist/data/mock/fixtures-gate.d.ts +3 -0
- package/dist/data/mock/fixtures-gate.js +49 -0
- package/dist/data/mock/fixtures-hooks.d.ts +2 -0
- package/dist/data/mock/fixtures-hooks.js +103 -0
- package/dist/data/mock/fixtures-inbox.d.ts +11 -0
- package/dist/data/mock/fixtures-inbox.js +213 -0
- package/dist/data/mock/fixtures-links.d.ts +5 -0
- package/dist/data/mock/fixtures-links.js +89 -0
- package/dist/data/mock/fixtures-memory.d.ts +3 -0
- package/dist/data/mock/fixtures-memory.js +148 -0
- package/dist/data/mock/fixtures-ops.d.ts +15 -0
- package/dist/data/mock/fixtures-ops.js +403 -0
- package/dist/data/mock/fixtures-optimise.d.ts +18 -0
- package/dist/data/mock/fixtures-optimise.js +221 -0
- package/dist/data/mock/fixtures-pacing.d.ts +5 -0
- package/dist/data/mock/fixtures-pacing.js +285 -0
- package/dist/data/mock/fixtures-publish.d.ts +2 -0
- package/dist/data/mock/fixtures-publish.js +129 -0
- package/dist/data/mock/fixtures-store.d.ts +10 -0
- package/dist/data/mock/fixtures-store.js +13 -0
- package/dist/data/mock/fixtures-variants.d.ts +5 -0
- package/dist/data/mock/fixtures-variants.js +138 -0
- package/dist/data/mock/fixtures.d.ts +6 -0
- package/dist/data/mock/fixtures.js +420 -0
- package/dist/data/mock/mockDataSource.d.ts +98 -0
- package/dist/data/mock/mockDataSource.js +903 -0
- package/dist/data/money.d.ts +14 -0
- package/dist/data/money.js +36 -0
- package/dist/data/platforms.d.ts +12 -0
- package/dist/data/platforms.js +26 -0
- package/dist/data/source.d.ts +196 -0
- package/dist/data/source.js +1 -0
- package/dist/data/time.d.ts +23 -0
- package/dist/data/time.js +86 -0
- package/dist/data/types.d.ts +1092 -0
- package/dist/data/types.js +3 -0
- package/dist/env.d.ts +59 -0
- package/dist/hooks/useAsync.d.ts +19 -0
- package/dist/hooks/useAsync.js +25 -0
- package/dist/index.d.ts +56 -0
- package/dist/index.js +58 -0
- package/dist/lib/host.d.ts +21 -0
- package/dist/lib/host.js +32 -0
- package/dist/lib/shell.d.ts +156 -0
- package/dist/lib/shell.js +16 -0
- package/dist/screens/AccountsScreen.d.ts +8 -0
- package/dist/screens/AccountsScreen.js +100 -0
- package/dist/screens/AnalyticsScreen.d.ts +7 -0
- package/dist/screens/AnalyticsScreen.js +64 -0
- package/dist/screens/ApprovalsScreen.d.ts +10 -0
- package/dist/screens/ApprovalsScreen.js +144 -0
- package/dist/screens/BriefsScreen.d.ts +7 -0
- package/dist/screens/BriefsScreen.js +83 -0
- package/dist/screens/CalendarScreen.d.ts +10 -0
- package/dist/screens/CalendarScreen.js +143 -0
- package/dist/screens/CampaignsScreen.d.ts +7 -0
- package/dist/screens/CampaignsScreen.js +52 -0
- package/dist/screens/ChannelMemoryScreen.d.ts +7 -0
- package/dist/screens/ChannelMemoryScreen.js +109 -0
- package/dist/screens/ContentScreen.d.ts +7 -0
- package/dist/screens/ContentScreen.js +64 -0
- package/dist/screens/DigestsScreen.d.ts +7 -0
- package/dist/screens/DigestsScreen.js +53 -0
- package/dist/screens/DraftsScreen.d.ts +10 -0
- package/dist/screens/DraftsScreen.js +108 -0
- package/dist/screens/FailuresScreen.d.ts +8 -0
- package/dist/screens/FailuresScreen.js +111 -0
- package/dist/screens/HookTestsScreen.d.ts +7 -0
- package/dist/screens/HookTestsScreen.js +54 -0
- package/dist/screens/InboxScreen.d.ts +12 -0
- package/dist/screens/InboxScreen.js +76 -0
- package/dist/screens/LinksScreen.d.ts +7 -0
- package/dist/screens/LinksScreen.js +52 -0
- package/dist/screens/PacingScreen.d.ts +7 -0
- package/dist/screens/PacingScreen.js +88 -0
- package/dist/screens/PublishJobsScreen.d.ts +7 -0
- package/dist/screens/PublishJobsScreen.js +66 -0
- package/dist/screens/SuggestionsScreen.d.ts +8 -0
- package/dist/screens/SuggestionsScreen.js +94 -0
- package/dist/screens/TextGateScreen.d.ts +7 -0
- package/dist/screens/TextGateScreen.js +111 -0
- package/dist/screens/ThreadsScreen.d.ts +8 -0
- package/dist/screens/ThreadsScreen.js +113 -0
- package/dist/screens/TypedBriefsScreen.d.ts +7 -0
- package/dist/screens/TypedBriefsScreen.js +75 -0
- package/dist/screens/labels.d.ts +121 -0
- package/dist/screens/labels.js +272 -0
- package/package.json +56 -3
|
@@ -0,0 +1,836 @@
|
|
|
1
|
+
// The DataSource backed by the supervised engine, through the shell (rk_shell_engine_call_tagged).
|
|
2
|
+
// Fields and methods the engine does not provide are named in docs/design/p9-desktop-screens-v1.md
|
|
3
|
+
// ("Engine gaps"). Reads throw EngineUnavailableError or EngineReadError, which the screens show as
|
|
4
|
+
// their load error. Writes return a Result, which the screens show as a notice.
|
|
5
|
+
import { engineCall } from "../../lib/host.js";
|
|
6
|
+
import { EngineReadError, EngineUnavailableError } from "./errors.js";
|
|
7
|
+
import { accountId, accountKey, accountOf, allowanceOf, attributionOf, campaignOf, channelLinksOf, draftOf, encodeRejection, engineInstant, entryToJob, hookTestOf, linkOf, parseAccountId, parseDraftKey, parseJobKey, parseScopeKey, priceOf, proposalOf, publishFailureOf, publishJobRecordOf, publishProgressFor, spendReportOf, contentDetailOf, contentRecordOf, digestApplicationOf, digestOf, experimentSummaryOf, briefDetailOf, briefOf, deliveryOf, dispatchAttemptOf, gateCheckOf, gatePolicyOf, gateRecordOf, memoryDetailOf, memoryRevisionOf, proofOf, typedBriefOf, briefVariantOf, analyticsObservationOf, analyticsPostsOf, metricImportOf, heartbeatStatusOf, restoreReceiptListOf, rowOf, inboxEventOf, inboxRawOf, inboxStatsOf, publishNextOf, publishQuotaOf, } from "./mapping.js";
|
|
8
|
+
/** The approver the engine records. The shell has no operator identity yet (see "Engine gaps"). */
|
|
9
|
+
const APPROVER = "desktop";
|
|
10
|
+
/** The newest page of inbox events the screen loads. A larger store shows a plain note that older events exist. */
|
|
11
|
+
const INBOX_PAGE = 200;
|
|
12
|
+
/** Recording a publish outcome or running the platform check happens on the daemon host. */
|
|
13
|
+
const DAEMON_ONLY_CHECK = "The platform check runs on the daemon host. The desktop cannot start it, so nothing was checked.";
|
|
14
|
+
const DAEMON_ONLY_RESOLVE = "Recording the outcome of a publish runs on the daemon host. The desktop cannot record it, so nothing was changed.";
|
|
15
|
+
function messageOf(error) {
|
|
16
|
+
return error instanceof Error ? error.message : String(error);
|
|
17
|
+
}
|
|
18
|
+
function unavailable(what) {
|
|
19
|
+
return {
|
|
20
|
+
ok: false,
|
|
21
|
+
error: { code: "engine_unavailable", message: `${what} is unavailable from the engine yet.` },
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function failed(error) {
|
|
25
|
+
return { ok: false, error };
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Engine error code to the operator's error code. Codes with a counterpart keep it; every other
|
|
29
|
+
* engine code becomes engine_error, which carries the engine's own code.
|
|
30
|
+
*/
|
|
31
|
+
function dataErrorFrom(error) {
|
|
32
|
+
switch (error.code) {
|
|
33
|
+
case "revision_conflict":
|
|
34
|
+
return { code: "revision_conflict", message: error.message };
|
|
35
|
+
case "foreign_scope":
|
|
36
|
+
return { code: "foreign_scope", message: error.message };
|
|
37
|
+
case "not_found":
|
|
38
|
+
return { code: "not_found", message: error.message };
|
|
39
|
+
case "bad_input":
|
|
40
|
+
return { code: "invalid_input", message: error.message };
|
|
41
|
+
default:
|
|
42
|
+
return { code: "engine_error", message: error.message, engineCode: error.code };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export class EngineDataSource {
|
|
46
|
+
kind = "daemon";
|
|
47
|
+
/** Accounts from the last listAccounts that the desktop could not map. */
|
|
48
|
+
unmapped = [];
|
|
49
|
+
/**
|
|
50
|
+
* The browser clock. The engine has no clock method yet, so countdowns use this value and not the
|
|
51
|
+
* daemon's clock. See "Engine gaps" in the design doc.
|
|
52
|
+
*/
|
|
53
|
+
nowUtc() {
|
|
54
|
+
return engineInstant(new Date().toISOString());
|
|
55
|
+
}
|
|
56
|
+
// ---- Reads ----------------------------------------------------------------------------------
|
|
57
|
+
/**
|
|
58
|
+
* Every stored account, from account.list, mapped to the operator's Account. Fields the engine
|
|
59
|
+
* does not hold take the named fallbacks in mapping.ts ("Accounts"). Each account maps on its own:
|
|
60
|
+
* one whose platform or state the desktop does not show yet is left out of the result and kept,
|
|
61
|
+
* with its typed error, in unmappedAccounts(). The other accounts still load. A failed call throws.
|
|
62
|
+
*/
|
|
63
|
+
async listAccounts() {
|
|
64
|
+
const nowUtc = this.nowUtc();
|
|
65
|
+
const list = await read("account.list", {});
|
|
66
|
+
const accounts = [];
|
|
67
|
+
const unmapped = [];
|
|
68
|
+
for (const engine of list.accounts) {
|
|
69
|
+
try {
|
|
70
|
+
accounts.push(accountOf(engine, nowUtc));
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
unmapped.push({
|
|
74
|
+
id: accountId(engine.scope, engine.platform),
|
|
75
|
+
platform: engine.platform,
|
|
76
|
+
error: error instanceof EngineReadError ? error : new EngineReadError("engine_mapping", messageOf(error)),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
this.unmapped = unmapped;
|
|
81
|
+
return accounts;
|
|
82
|
+
}
|
|
83
|
+
/** The accounts the last listAccounts could not map, each with the reason the desktop gives. */
|
|
84
|
+
unmappedAccounts() {
|
|
85
|
+
return this.unmapped.map((row) => ({ id: row.id, platform: row.platform, reason: row.error.message }));
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Every calendar entry in the range. Each entry carries its publish progress (permalink, waiting
|
|
89
|
+
* time, failure) from the engine's publish job for that entry revision. Publish reads run in every
|
|
90
|
+
* role, so the desktop sees the daemon's progress.
|
|
91
|
+
*/
|
|
92
|
+
async listJobs(range) {
|
|
93
|
+
const from = engineInstant(range.fromUtc);
|
|
94
|
+
const to = engineInstant(range.toUtc);
|
|
95
|
+
const scopes = await this.scopes();
|
|
96
|
+
const [lists, publishByScope] = await Promise.all([
|
|
97
|
+
Promise.all(scopes.map((scope) => read("calendar.list", { scope, from, to }))),
|
|
98
|
+
Promise.all(scopes.map((scope) => this.publishJobsFor(scope))),
|
|
99
|
+
]);
|
|
100
|
+
const jobs = lists.flatMap((list, index) => {
|
|
101
|
+
const publishJobs = publishByScope[index];
|
|
102
|
+
return list.entries.map((entry) => {
|
|
103
|
+
const job = entryToJob(entry);
|
|
104
|
+
job.publish = publishProgressFor(publishJobs, entry);
|
|
105
|
+
return job;
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
return jobs.sort((a, b) => a.scheduledAtUtc.localeCompare(b.scheduledAtUtc) || a.id.localeCompare(b.id));
|
|
109
|
+
}
|
|
110
|
+
/** Null when the id is unknown to the engine for its scope (not_found). */
|
|
111
|
+
async getJob(jobId) {
|
|
112
|
+
const key = parseJobKey(jobId);
|
|
113
|
+
if (!key)
|
|
114
|
+
return null;
|
|
115
|
+
const reply = await engineCall("calendar.get", { scope: key.scope, entry_id: key.entryId });
|
|
116
|
+
if (!reply.ok) {
|
|
117
|
+
if (reply.error.code === "not_found")
|
|
118
|
+
return null;
|
|
119
|
+
throw new EngineReadError(reply.error.code, `calendar.get: ${reply.error.message}`);
|
|
120
|
+
}
|
|
121
|
+
const job = entryToJob(reply.value);
|
|
122
|
+
const publishJobs = await this.publishJobsFor(key.scope);
|
|
123
|
+
job.publish = publishProgressFor(publishJobs, reply.value);
|
|
124
|
+
return job;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Failure rows from publish jobs in state failed, outcome_unknown, or cancelled with a reason, from
|
|
128
|
+
* publish.list per scope. A failed read throws (the screen's load error), so an empty list always
|
|
129
|
+
* means no failures. The failures do not depend on the digest, which still has no engine source.
|
|
130
|
+
*/
|
|
131
|
+
async listFailures() {
|
|
132
|
+
const scopes = await this.scopes();
|
|
133
|
+
const failures = [];
|
|
134
|
+
for (const scope of scopes) {
|
|
135
|
+
const publishJobs = await this.publishJobsFor(scope);
|
|
136
|
+
for (const job of publishJobs) {
|
|
137
|
+
const failure = publishFailureOf(job);
|
|
138
|
+
if (failure !== null)
|
|
139
|
+
failures.push(failure);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return failures;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Every publish job in one scope, from publish.list, newest first. A read of the stored job only: the desktop
|
|
146
|
+
* never steps, runs, reconciles, cancels or resolves a job. A failed read throws; an empty list is a state.
|
|
147
|
+
*/
|
|
148
|
+
async listPublishJobs(scopeId) {
|
|
149
|
+
const jobs = await this.publishJobsFor(scopeOf(scopeId));
|
|
150
|
+
return jobs
|
|
151
|
+
.map((job) => publishJobRecordOf(job))
|
|
152
|
+
.sort((a, b) => b.createdAtUtc.localeCompare(a.createdAtUtc) || a.jobId.localeCompare(b.jobId));
|
|
153
|
+
}
|
|
154
|
+
async listDigest() {
|
|
155
|
+
throw new EngineUnavailableError("The digest is not in the engine yet.");
|
|
156
|
+
}
|
|
157
|
+
async listThreads() {
|
|
158
|
+
throw new EngineUnavailableError("Threads are not in the engine yet.");
|
|
159
|
+
}
|
|
160
|
+
async listModerationCapabilities() {
|
|
161
|
+
throw new EngineUnavailableError("Moderation capabilities are not in the engine yet.");
|
|
162
|
+
}
|
|
163
|
+
// ---- Writes ---------------------------------------------------------------------------------
|
|
164
|
+
/**
|
|
165
|
+
* Approves exactly `revision`. The account is checked here first, as the daemon does; the engine
|
|
166
|
+
* then checks the revision itself. The engine approves without checking media readiness, so the
|
|
167
|
+
* screen's own gate is the only media check (see "Engine gaps").
|
|
168
|
+
*/
|
|
169
|
+
async approve(jobId, revision, expectedAccountId) {
|
|
170
|
+
const key = parseJobKey(jobId);
|
|
171
|
+
if (!key)
|
|
172
|
+
return failed({ code: "not_found", message: `No job ${jobId} in the engine.` });
|
|
173
|
+
const current = await engineCall("calendar.get", { scope: key.scope, entry_id: key.entryId });
|
|
174
|
+
if (!current.ok)
|
|
175
|
+
return this.failure(current.error, key.scope, key.entryId);
|
|
176
|
+
if (accountId(key.scope, current.value.platform) !== expectedAccountId) {
|
|
177
|
+
return failed({ code: "account_mismatch", message: "This job belongs to another account. Nothing was approved." });
|
|
178
|
+
}
|
|
179
|
+
const reply = await engineCall("calendar.approve", {
|
|
180
|
+
scope: key.scope,
|
|
181
|
+
entry_id: key.entryId,
|
|
182
|
+
expected_revision: revision,
|
|
183
|
+
approved_by: APPROVER,
|
|
184
|
+
policy_revision: current.value.policy_revision ?? 0,
|
|
185
|
+
});
|
|
186
|
+
return this.entryResult(reply, key.scope, key.entryId);
|
|
187
|
+
}
|
|
188
|
+
async reject(jobId, decision) {
|
|
189
|
+
const key = parseJobKey(jobId);
|
|
190
|
+
if (!key)
|
|
191
|
+
return failed({ code: "not_found", message: `No job ${jobId} in the engine.` });
|
|
192
|
+
const current = await engineCall("calendar.get", { scope: key.scope, entry_id: key.entryId });
|
|
193
|
+
if (!current.ok)
|
|
194
|
+
return this.failure(current.error, key.scope, key.entryId);
|
|
195
|
+
if (accountId(key.scope, current.value.platform) !== decision.accountId) {
|
|
196
|
+
return failed({ code: "account_mismatch", message: "This job belongs to another account. Nothing was rejected." });
|
|
197
|
+
}
|
|
198
|
+
const reply = await engineCall("calendar.reject", {
|
|
199
|
+
scope: key.scope,
|
|
200
|
+
entry_id: key.entryId,
|
|
201
|
+
expected_revision: decision.revision,
|
|
202
|
+
reason: encodeRejection(decision.code, decision.note),
|
|
203
|
+
});
|
|
204
|
+
return this.entryResult(reply, key.scope, key.entryId);
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Pauses one account. The engine's pause needs the revision the operator saw; the desktop reads the
|
|
208
|
+
* current revision first, so the write is a compare-and-swap against that read. A revision that
|
|
209
|
+
* moved in between is revision_conflict and nothing is paused.
|
|
210
|
+
*/
|
|
211
|
+
async pauseAccount(id, reason) {
|
|
212
|
+
const key = parseAccountId(id);
|
|
213
|
+
if (!key)
|
|
214
|
+
return failed({ code: "not_found", message: `No account ${id} in the engine.` });
|
|
215
|
+
const current = await engineCall("account.get", { scope: key.scope, platform: key.platform });
|
|
216
|
+
if (!current.ok)
|
|
217
|
+
return failed(dataErrorFrom(current.error));
|
|
218
|
+
const reply = await engineCall("account.pause", {
|
|
219
|
+
scope: key.scope,
|
|
220
|
+
platform: key.platform,
|
|
221
|
+
reason,
|
|
222
|
+
expected_revision: current.value.revision,
|
|
223
|
+
});
|
|
224
|
+
return this.accountResult(reply);
|
|
225
|
+
}
|
|
226
|
+
/** Resumes one account, with the same revision read and compare-and-swap as pauseAccount. */
|
|
227
|
+
async resumeAccount(id) {
|
|
228
|
+
const key = parseAccountId(id);
|
|
229
|
+
if (!key)
|
|
230
|
+
return failed({ code: "not_found", message: `No account ${id} in the engine.` });
|
|
231
|
+
const current = await engineCall("account.get", { scope: key.scope, platform: key.platform });
|
|
232
|
+
if (!current.ok)
|
|
233
|
+
return failed(dataErrorFrom(current.error));
|
|
234
|
+
const reply = await engineCall("account.resume", {
|
|
235
|
+
scope: key.scope,
|
|
236
|
+
platform: key.platform,
|
|
237
|
+
expected_revision: current.value.revision,
|
|
238
|
+
});
|
|
239
|
+
return this.accountResult(reply);
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Disconnects one account with account.disconnect: the connection ends and the account's queued and waiting
|
|
243
|
+
* publish jobs are cancelled in one engine transaction. Same revision read and compare-and-swap as pauseAccount.
|
|
244
|
+
*/
|
|
245
|
+
async disconnectAccount(id) {
|
|
246
|
+
const key = parseAccountId(id);
|
|
247
|
+
if (!key)
|
|
248
|
+
return failed({ code: "not_found", message: `No account ${id} in the engine.` });
|
|
249
|
+
const current = await engineCall("account.get", { scope: key.scope, platform: key.platform });
|
|
250
|
+
if (!current.ok)
|
|
251
|
+
return failed(dataErrorFrom(current.error));
|
|
252
|
+
const reply = await engineCall("account.disconnect", {
|
|
253
|
+
scope: key.scope,
|
|
254
|
+
platform: key.platform,
|
|
255
|
+
expected_revision: current.value.revision,
|
|
256
|
+
});
|
|
257
|
+
if (!reply.ok)
|
|
258
|
+
return failed(dataErrorFrom(reply.error));
|
|
259
|
+
try {
|
|
260
|
+
return {
|
|
261
|
+
ok: true,
|
|
262
|
+
value: {
|
|
263
|
+
account: accountOf(reply.value.account, this.nowUtc()),
|
|
264
|
+
cancelledJobs: reply.value.cancelled_jobs.length,
|
|
265
|
+
keptJobs: reply.value.kept_jobs.length,
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
catch (error) {
|
|
270
|
+
return failed({ code: "engine_error", message: messageOf(error), engineCode: "engine_mapping" });
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* The platform check is publish.reconcile, which is refused in the desktop role. It never reaches
|
|
275
|
+
* the engine from here, so the desktop makes no platform call.
|
|
276
|
+
*/
|
|
277
|
+
async reconcile(_jobId) {
|
|
278
|
+
return failed({ code: "engine_unavailable", message: DAEMON_ONLY_CHECK });
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Publish failures are resolved on the daemon (publish.reconcile and publish.cancel are role-gated).
|
|
282
|
+
* Other failure kinds have no engine method yet.
|
|
283
|
+
*/
|
|
284
|
+
async resolveFailure(failureId, _resolution, _note) {
|
|
285
|
+
if (failureId.startsWith("publish-")) {
|
|
286
|
+
return failed({ code: "engine_unavailable", message: DAEMON_ONLY_RESOLVE });
|
|
287
|
+
}
|
|
288
|
+
return unavailable("Resolving a failure");
|
|
289
|
+
}
|
|
290
|
+
async resolveThread(_threadId) {
|
|
291
|
+
return unavailable("Resolving a thread");
|
|
292
|
+
}
|
|
293
|
+
async snoozeThread(_threadId, _untilUtc) {
|
|
294
|
+
return unavailable("Snoozing a thread");
|
|
295
|
+
}
|
|
296
|
+
// ---- Manual drafts --------------------------------------------------------------------------
|
|
297
|
+
// the host app never posts a draft. These calls store and check it; the operator posts by hand and
|
|
298
|
+
// records the link. The engine makes no network call for any of them.
|
|
299
|
+
/** Every draft in every stored channel, from draft.list per scope, newest update first. */
|
|
300
|
+
async listDrafts() {
|
|
301
|
+
const scopes = await this.scopes();
|
|
302
|
+
const lists = await Promise.all(scopes.map((scope) => read("draft.list", { scope })));
|
|
303
|
+
return lists
|
|
304
|
+
.flatMap((list) => list.drafts.map((draft) => draftOf(draft)))
|
|
305
|
+
.sort((a, b) => b.updatedAtUtc.localeCompare(a.updatedAtUtc) || a.id.localeCompare(b.id));
|
|
306
|
+
}
|
|
307
|
+
/** Approves exactly `revision` when the engine's gate passes. The disclosure phrases come from the caller. */
|
|
308
|
+
async approveDraft(draftId, revision, disclosurePhrases) {
|
|
309
|
+
const key = parseDraftKey(draftId);
|
|
310
|
+
if (!key)
|
|
311
|
+
return failed({ code: "not_found", message: `No draft ${draftId} in the engine.` });
|
|
312
|
+
const reply = await engineCall("draft.approve", {
|
|
313
|
+
scope: key.scope,
|
|
314
|
+
draft_id: key.draftId,
|
|
315
|
+
expected_revision: revision,
|
|
316
|
+
disclosure_phrases: [...disclosurePhrases],
|
|
317
|
+
});
|
|
318
|
+
return this.draftResult(reply);
|
|
319
|
+
}
|
|
320
|
+
/** Records the operator's post. The engine checks the link: https, on an allowed host for the platform. */
|
|
321
|
+
async markDraftPosted(draftId, revision, postedUrl) {
|
|
322
|
+
const key = parseDraftKey(draftId);
|
|
323
|
+
if (!key)
|
|
324
|
+
return failed({ code: "not_found", message: `No draft ${draftId} in the engine.` });
|
|
325
|
+
const reply = await engineCall("draft.mark_posted", {
|
|
326
|
+
scope: key.scope,
|
|
327
|
+
draft_id: key.draftId,
|
|
328
|
+
expected_revision: revision,
|
|
329
|
+
posted_url: postedUrl,
|
|
330
|
+
});
|
|
331
|
+
if (!reply.ok)
|
|
332
|
+
return failed(dataErrorFrom(reply.error));
|
|
333
|
+
return this.draftResult({ ok: true, value: reply.value.draft });
|
|
334
|
+
}
|
|
335
|
+
/** Ends a drafted or approved draft with a reason. The engine refuses a blank reason. */
|
|
336
|
+
async skipDraft(draftId, revision, reason) {
|
|
337
|
+
const key = parseDraftKey(draftId);
|
|
338
|
+
if (!key)
|
|
339
|
+
return failed({ code: "not_found", message: `No draft ${draftId} in the engine.` });
|
|
340
|
+
const reply = await engineCall("draft.skip", {
|
|
341
|
+
scope: key.scope,
|
|
342
|
+
draft_id: key.draftId,
|
|
343
|
+
expected_revision: revision,
|
|
344
|
+
reason,
|
|
345
|
+
});
|
|
346
|
+
return this.draftResult(reply);
|
|
347
|
+
}
|
|
348
|
+
// ---- Suggestions and spend ------------------------------------------------------------------
|
|
349
|
+
// Suggestions are decided on the desktop: accept creates one draft calendar entry, dismiss creates
|
|
350
|
+
// nothing. Spend is read-only. The engine's "no allowance" answers are states, so they return null.
|
|
351
|
+
/** Every stored channel, from channel.list, labelled as venture / channel / account. */
|
|
352
|
+
async listScopes() {
|
|
353
|
+
const list = await read("channel.list", {});
|
|
354
|
+
return list.channels.map((row) => ({
|
|
355
|
+
id: accountKey(row.scope),
|
|
356
|
+
label: `${row.scope.venture} / ${row.scope.channel} / ${row.scope.account}`,
|
|
357
|
+
}));
|
|
358
|
+
}
|
|
359
|
+
/** Every suggestion in one scope, from optimise.list, in the engine's creation order. */
|
|
360
|
+
async listProposals(scopeId) {
|
|
361
|
+
const list = await read("optimise.list", { scope: scopeOf(scopeId) });
|
|
362
|
+
return list.proposals.map((proposal) => proposalOf(proposal));
|
|
363
|
+
}
|
|
364
|
+
/** One suggestion with its lineage, from optimise.get. Null when the engine does not hold it in this scope. */
|
|
365
|
+
async getProposal(scopeId, proposalId) {
|
|
366
|
+
const reply = await engineCall("optimise.get", {
|
|
367
|
+
scope: scopeOf(scopeId),
|
|
368
|
+
proposal_id: proposalId,
|
|
369
|
+
});
|
|
370
|
+
if (!reply.ok) {
|
|
371
|
+
if (reply.error.code === "not_found")
|
|
372
|
+
return null;
|
|
373
|
+
throw new EngineReadError(reply.error.code, `optimise.get: ${reply.error.message}`);
|
|
374
|
+
}
|
|
375
|
+
return proposalOf(reply.value);
|
|
376
|
+
}
|
|
377
|
+
/** Accepts at exactly `expectedRevision`. The engine creates the draft entry; nothing is approved here. */
|
|
378
|
+
async acceptProposal(scopeId, proposalId, expectedRevision) {
|
|
379
|
+
return this.decideProposal("optimise.accept", scopeId, proposalId, expectedRevision);
|
|
380
|
+
}
|
|
381
|
+
/** Dismisses at exactly `expectedRevision`. The engine writes only the suggestion row. */
|
|
382
|
+
async dismissProposal(scopeId, proposalId, expectedRevision) {
|
|
383
|
+
return this.decideProposal("optimise.dismiss", scopeId, proposalId, expectedRevision);
|
|
384
|
+
}
|
|
385
|
+
/** The month's allowance, from allowance.get. The engine's not_found is "no allowance set", so it returns null. */
|
|
386
|
+
async getAllowance(scopeId, month) {
|
|
387
|
+
const reply = await engineCall("allowance.get", { scope: scopeOf(scopeId), month });
|
|
388
|
+
if (!reply.ok) {
|
|
389
|
+
if (reply.error.code === "not_found")
|
|
390
|
+
return null;
|
|
391
|
+
throw new EngineReadError(reply.error.code, `allowance.get: ${reply.error.message}`);
|
|
392
|
+
}
|
|
393
|
+
return allowanceOf(reply.value);
|
|
394
|
+
}
|
|
395
|
+
/** The month report, from spend.report. The engine's allowance_missing is "no allowance set", so it returns null. */
|
|
396
|
+
async spendReport(scopeId, month) {
|
|
397
|
+
const reply = await engineCall("spend.report", { scope: scopeOf(scopeId), month });
|
|
398
|
+
if (!reply.ok) {
|
|
399
|
+
if (reply.error.code === "allowance_missing")
|
|
400
|
+
return null;
|
|
401
|
+
throw new EngineReadError(reply.error.code, `spend.report: ${reply.error.message}`);
|
|
402
|
+
}
|
|
403
|
+
return spendReportOf(reply.value);
|
|
404
|
+
}
|
|
405
|
+
/** The latest version of every price route, from price.list, with qualification at the browser clock. */
|
|
406
|
+
async listPrices() {
|
|
407
|
+
const list = await read("price.list", { now: this.nowUtc() });
|
|
408
|
+
return list.prices.map((price) => priceOf(price));
|
|
409
|
+
}
|
|
410
|
+
// ---- Campaigns, tracked links and hook tests (read only) ----------------------------------
|
|
411
|
+
// The desktop never calls campaign.put, link.create, link.record_visit, hook_test.create, bind or read.
|
|
412
|
+
/** Every campaign in one scope, from campaign.list. */
|
|
413
|
+
async listCampaigns(scopeId) {
|
|
414
|
+
const list = await read("campaign.list", { scope: scopeOf(scopeId) });
|
|
415
|
+
return list.campaigns.map((campaign) => campaignOf(campaign));
|
|
416
|
+
}
|
|
417
|
+
/** The tracked links of one campaign, from link.list filtered by campaign id. */
|
|
418
|
+
async listCampaignLinks(scopeId, campaignId) {
|
|
419
|
+
const list = await read("link.list", { scope: scopeOf(scopeId), campaign_id: campaignId });
|
|
420
|
+
return list.links.map((link) => linkOf(link));
|
|
421
|
+
}
|
|
422
|
+
/** The visit counts of one campaign, from link.attribution. Null when the engine holds no such campaign in the scope. */
|
|
423
|
+
async campaignAttribution(scopeId, campaignId) {
|
|
424
|
+
const reply = await engineCall("link.attribution", {
|
|
425
|
+
scope: scopeOf(scopeId),
|
|
426
|
+
campaign_id: campaignId,
|
|
427
|
+
});
|
|
428
|
+
if (!reply.ok) {
|
|
429
|
+
if (reply.error.code === "not_found")
|
|
430
|
+
return null;
|
|
431
|
+
throw new EngineReadError(reply.error.code, `link.attribution: ${reply.error.message}`);
|
|
432
|
+
}
|
|
433
|
+
return attributionOf(reply.value);
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Every tracked link in one scope. campaign.list gives the campaigns; for each one, link.list and link.attribution
|
|
437
|
+
* are read together. Both are read-only, and any failed read throws, so the screen shows its load error.
|
|
438
|
+
*/
|
|
439
|
+
async listChannelLinks(scopeId) {
|
|
440
|
+
const scope = scopeOf(scopeId);
|
|
441
|
+
const campaigns = await read("campaign.list", { scope });
|
|
442
|
+
const perCampaign = await Promise.all(campaigns.campaigns.map(async (campaign) => {
|
|
443
|
+
const [links, attribution] = await Promise.all([
|
|
444
|
+
read("link.list", { scope, campaign_id: campaign.campaign_id }),
|
|
445
|
+
read("link.attribution", { scope, campaign_id: campaign.campaign_id }),
|
|
446
|
+
]);
|
|
447
|
+
return channelLinksOf(campaign.name, links.links, attribution);
|
|
448
|
+
}));
|
|
449
|
+
return perCampaign.flat();
|
|
450
|
+
}
|
|
451
|
+
/** Every hook test set in one scope, from hook_test.list. Reading the sets here decides nothing. */
|
|
452
|
+
async listHookTests(scopeId) {
|
|
453
|
+
const list = await read("hook_test.list", { scope: scopeOf(scopeId) });
|
|
454
|
+
return list.sets.map((set) => hookTestOf(set));
|
|
455
|
+
}
|
|
456
|
+
/** One hook test set with its openings, from hook_test.get. Null when no set with this id is in the scope. */
|
|
457
|
+
async getHookTest(scopeId, setId) {
|
|
458
|
+
const reply = await engineCall("hook_test.get", { scope: scopeOf(scopeId), set_id: setId });
|
|
459
|
+
if (!reply.ok) {
|
|
460
|
+
if (reply.error.code === "not_found")
|
|
461
|
+
return null;
|
|
462
|
+
throw new EngineReadError(reply.error.code, `hook_test.get: ${reply.error.message}`);
|
|
463
|
+
}
|
|
464
|
+
return hookTestOf(reply.value);
|
|
465
|
+
}
|
|
466
|
+
// ---- Research digests and content library (read only) --------------------------------------
|
|
467
|
+
// The desktop never calls digest.put, digest.plan, digest.apply, content.put, content.withdraw,
|
|
468
|
+
// content.add_variant, content.plan_occurrence, content.cancel_occurrence or content.mark_published.
|
|
469
|
+
/** Every digest in one scope, from digest.list. */
|
|
470
|
+
async listDigests(scopeId) {
|
|
471
|
+
const list = await read("digest.list", { scope: scopeOf(scopeId) });
|
|
472
|
+
return list.digests.map((digest) => digestOf(digest));
|
|
473
|
+
}
|
|
474
|
+
/** One digest with its entries, from digest.get. Null when no digest with this id is in the scope. */
|
|
475
|
+
async getDigest(scopeId, digestId) {
|
|
476
|
+
const reply = await engineCall("digest.get", {
|
|
477
|
+
scope: scopeOf(scopeId),
|
|
478
|
+
digest_id: digestId,
|
|
479
|
+
});
|
|
480
|
+
if (!reply.ok) {
|
|
481
|
+
if (reply.error.code === "not_found")
|
|
482
|
+
return null;
|
|
483
|
+
throw new EngineReadError(reply.error.code, `digest.get: ${reply.error.message}`);
|
|
484
|
+
}
|
|
485
|
+
return digestOf(reply.value);
|
|
486
|
+
}
|
|
487
|
+
/** Which digest entries became draft calendar entries, from digest.applied. Null when the digest is not in the scope. */
|
|
488
|
+
async digestApplications(scopeId, digestId) {
|
|
489
|
+
const reply = await engineCall("digest.applied", {
|
|
490
|
+
scope: scopeOf(scopeId),
|
|
491
|
+
digest_id: digestId,
|
|
492
|
+
});
|
|
493
|
+
if (!reply.ok) {
|
|
494
|
+
if (reply.error.code === "not_found")
|
|
495
|
+
return null;
|
|
496
|
+
throw new EngineReadError(reply.error.code, `digest.applied: ${reply.error.message}`);
|
|
497
|
+
}
|
|
498
|
+
return reply.value.applications.map((application) => digestApplicationOf(application, reply.value.scope));
|
|
499
|
+
}
|
|
500
|
+
/** Every content record in one scope, from content.list, read at the browser clock so expiry is derived. */
|
|
501
|
+
async listContent(scopeId) {
|
|
502
|
+
const list = await read("content.list", { scope: scopeOf(scopeId), now: this.nowUtc() });
|
|
503
|
+
return list.records.map((record) => contentRecordOf(record));
|
|
504
|
+
}
|
|
505
|
+
/** One record with its variants, from content.get. Null when no record with this id is in the scope. */
|
|
506
|
+
async getContent(scopeId, contentId) {
|
|
507
|
+
const reply = await engineCall("content.get", {
|
|
508
|
+
scope: scopeOf(scopeId),
|
|
509
|
+
content_id: contentId,
|
|
510
|
+
now: this.nowUtc(),
|
|
511
|
+
});
|
|
512
|
+
if (!reply.ok) {
|
|
513
|
+
if (reply.error.code === "not_found")
|
|
514
|
+
return null;
|
|
515
|
+
throw new EngineReadError(reply.error.code, `content.get: ${reply.error.message}`);
|
|
516
|
+
}
|
|
517
|
+
return contentDetailOf(reply.value);
|
|
518
|
+
}
|
|
519
|
+
/** The variant comparison for one record, from content.experiment_summary. Null when the record is not in the scope. */
|
|
520
|
+
async contentExperimentSummary(scopeId, contentId) {
|
|
521
|
+
const reply = await engineCall("content.experiment_summary", {
|
|
522
|
+
scope: scopeOf(scopeId),
|
|
523
|
+
content_id: contentId,
|
|
524
|
+
});
|
|
525
|
+
if (!reply.ok) {
|
|
526
|
+
if (reply.error.code === "not_found")
|
|
527
|
+
return null;
|
|
528
|
+
throw new EngineReadError(reply.error.code, `content.experiment_summary: ${reply.error.message}`);
|
|
529
|
+
}
|
|
530
|
+
return experimentSummaryOf(reply.value);
|
|
531
|
+
}
|
|
532
|
+
// ---- Text gate, briefs, deliveries and dispatch attempts (read only) -------------------------
|
|
533
|
+
// The desktop never calls gate_policy.put, sends record: true, or calls brief_out.put, delivery.accept or
|
|
534
|
+
// dispatch.run. The check below asks for no record and carries no claim annotations.
|
|
535
|
+
/** The latest gate policy, from gate_policy.get. Null when the engine answers policy_missing: nothing can pass. */
|
|
536
|
+
async gatePolicy(scopeId) {
|
|
537
|
+
const reply = await engineCall("gate_policy.get", { scope: scopeOf(scopeId) });
|
|
538
|
+
if (!reply.ok) {
|
|
539
|
+
if (reply.error.code === "policy_missing")
|
|
540
|
+
return null;
|
|
541
|
+
throw new EngineReadError(reply.error.code, `gate_policy.get: ${reply.error.message}`);
|
|
542
|
+
}
|
|
543
|
+
return gatePolicyOf(reply.value);
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* A dry-run check of one script, from gate.check. Nothing is stored. A refusal (hidden characters, no
|
|
547
|
+
* policy) comes back as a Result with the engine's code, so the screen shows it as a notice.
|
|
548
|
+
*/
|
|
549
|
+
async checkText(scopeId, subjectId, text) {
|
|
550
|
+
const scope = parseScopeKey(scopeId);
|
|
551
|
+
if (!scope)
|
|
552
|
+
return failed({ code: "not_found", message: `No channel ${scopeId} in the engine.` });
|
|
553
|
+
const reply = await engineCall("gate.check", {
|
|
554
|
+
scope,
|
|
555
|
+
subject_id: subjectId,
|
|
556
|
+
text,
|
|
557
|
+
claims: [],
|
|
558
|
+
record: false,
|
|
559
|
+
});
|
|
560
|
+
if (!reply.ok)
|
|
561
|
+
return failed(dataErrorFrom(reply.error));
|
|
562
|
+
try {
|
|
563
|
+
return { ok: true, value: gateCheckOf(reply.value) };
|
|
564
|
+
}
|
|
565
|
+
catch (error) {
|
|
566
|
+
return failed({ code: "engine_error", message: messageOf(error), engineCode: "engine_mapping" });
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
/** The stored verdict records of one subject, newest first, from gate.records. */
|
|
570
|
+
async gateRecords(scopeId, subjectId) {
|
|
571
|
+
const list = await read("gate.records", { scope: scopeOf(scopeId), subject_id: subjectId });
|
|
572
|
+
return list.records.map((record) => gateRecordOf(record, scopeId));
|
|
573
|
+
}
|
|
574
|
+
/** Every outgoing brief in one scope, from brief_out.list. */
|
|
575
|
+
async listBriefs(scopeId) {
|
|
576
|
+
const list = await read("brief_out.list", { scope: scopeOf(scopeId) });
|
|
577
|
+
return list.briefs.map((brief) => briefOf(brief));
|
|
578
|
+
}
|
|
579
|
+
/** One brief with its summary and document, from brief_out.get. Null when no brief has this request id in the scope. */
|
|
580
|
+
async getBrief(scopeId, requestId) {
|
|
581
|
+
const reply = await engineCall("brief_out.get", {
|
|
582
|
+
scope: scopeOf(scopeId),
|
|
583
|
+
request_id: requestId,
|
|
584
|
+
});
|
|
585
|
+
if (!reply.ok) {
|
|
586
|
+
if (reply.error.code === "not_found")
|
|
587
|
+
return null;
|
|
588
|
+
throw new EngineReadError(reply.error.code, `brief_out.get: ${reply.error.message}`);
|
|
589
|
+
}
|
|
590
|
+
return briefDetailOf(reply.value);
|
|
591
|
+
}
|
|
592
|
+
/** Every accepted delivery in one scope, from delivery.list. */
|
|
593
|
+
async listDeliveries(scopeId) {
|
|
594
|
+
const list = await read("delivery.list", { scope: scopeOf(scopeId) });
|
|
595
|
+
return list.deliveries.map((delivery) => deliveryOf(delivery));
|
|
596
|
+
}
|
|
597
|
+
/** One delivery envelope, from delivery.get. Null when no envelope has this id in the scope. */
|
|
598
|
+
async getDelivery(scopeId, envelopeId) {
|
|
599
|
+
const reply = await engineCall("delivery.get", {
|
|
600
|
+
scope: scopeOf(scopeId),
|
|
601
|
+
envelope_id: envelopeId,
|
|
602
|
+
});
|
|
603
|
+
if (!reply.ok) {
|
|
604
|
+
if (reply.error.code === "not_found")
|
|
605
|
+
return null;
|
|
606
|
+
throw new EngineReadError(reply.error.code, `delivery.get: ${reply.error.message}`);
|
|
607
|
+
}
|
|
608
|
+
return deliveryOf(reply.value);
|
|
609
|
+
}
|
|
610
|
+
/** The dispatch attempts of one brief, from dispatch.list filtered by request id, oldest first. */
|
|
611
|
+
async listDispatchAttempts(scopeId, requestId) {
|
|
612
|
+
const list = await read("dispatch.list", { scope: scopeOf(scopeId), request_id: requestId });
|
|
613
|
+
return list.attempts.map((attempt) => dispatchAttemptOf(attempt, scopeId));
|
|
614
|
+
}
|
|
615
|
+
// ---- Channel memory and the proof bank (read only) -----------------------------------------
|
|
616
|
+
// The desktop never calls memory.put, channel.import or proof.apply_history.
|
|
617
|
+
/** The latest memory revision with its document, from memory.get. Null when the engine answers not_found: nothing written yet. */
|
|
618
|
+
async channelMemory(scopeId) {
|
|
619
|
+
const reply = await engineCall("memory.get", { scope: scopeOf(scopeId) });
|
|
620
|
+
if (!reply.ok) {
|
|
621
|
+
if (reply.error.code === "not_found")
|
|
622
|
+
return null;
|
|
623
|
+
throw new EngineReadError(reply.error.code, `memory.get: ${reply.error.message}`);
|
|
624
|
+
}
|
|
625
|
+
return memoryDetailOf(reply.value);
|
|
626
|
+
}
|
|
627
|
+
/** Every stored memory revision, newest first, from memory.history. An empty list when nothing is stored. */
|
|
628
|
+
async channelMemoryHistory(scopeId) {
|
|
629
|
+
const list = await read("memory.history", { scope: scopeOf(scopeId) });
|
|
630
|
+
return list.revisions.map((row) => memoryRevisionOf(row, scopeId));
|
|
631
|
+
}
|
|
632
|
+
/** The proofs of one scope, from proof.list. A null status sends no status filter, so every status is listed. */
|
|
633
|
+
async listProofs(scopeId, status) {
|
|
634
|
+
const scope = scopeOf(scopeId);
|
|
635
|
+
const params = status === null ? { scope } : { scope, status };
|
|
636
|
+
const list = await read("proof.list", params);
|
|
637
|
+
return list.proofs.map((row) => proofOf(row));
|
|
638
|
+
}
|
|
639
|
+
// ---- Typed briefs and platform variants (read only) -----------------------------------------
|
|
640
|
+
// The desktop never calls typed_brief.put, variant.derive or variant.derive; rules come from the platform-rules catalog.
|
|
641
|
+
/** Every typed brief in one scope, from typed_brief.list, ordered by brief id. */
|
|
642
|
+
async listTypedBriefs(scopeId) {
|
|
643
|
+
const list = await read("typed_brief.list", { scope: scopeOf(scopeId) });
|
|
644
|
+
return list.briefs.map((brief) => typedBriefOf(brief));
|
|
645
|
+
}
|
|
646
|
+
/** One brief, from typed_brief.get. Null when the engine answers not_found: no brief with this id in the scope. */
|
|
647
|
+
async getTypedBrief(scopeId, briefId) {
|
|
648
|
+
const reply = await engineCall("typed_brief.get", {
|
|
649
|
+
scope: scopeOf(scopeId),
|
|
650
|
+
brief_id: briefId,
|
|
651
|
+
});
|
|
652
|
+
if (!reply.ok) {
|
|
653
|
+
if (reply.error.code === "not_found")
|
|
654
|
+
return null;
|
|
655
|
+
throw new EngineReadError(reply.error.code, `typed_brief.get: ${reply.error.message}`);
|
|
656
|
+
}
|
|
657
|
+
return typedBriefOf(reply.value);
|
|
658
|
+
}
|
|
659
|
+
/** The variants of one brief, from variant.list, ordered by platform then variant id. */
|
|
660
|
+
async listBriefVariants(scopeId, briefId) {
|
|
661
|
+
const list = await read("variant.list", { scope: scopeOf(scopeId), brief_id: briefId });
|
|
662
|
+
return list.variants.map((variant) => briefVariantOf(variant));
|
|
663
|
+
}
|
|
664
|
+
// ---- Analytics and manual imports (read only) -----------------------------------------------
|
|
665
|
+
// The desktop never calls analytics.pull, metric_import.preview or metric_import.commit. Numbers are pulled on
|
|
666
|
+
// the daemon; this reads what is stored.
|
|
667
|
+
/** Every published post in one scope with its latest metrics, from analytics.summary. */
|
|
668
|
+
async listAnalyticsPosts(scopeId) {
|
|
669
|
+
const summary = await read("analytics.summary", { scope: scopeOf(scopeId) });
|
|
670
|
+
return analyticsPostsOf(summary);
|
|
671
|
+
}
|
|
672
|
+
/** The stored observations of one post, from analytics.list filtered to its job. The engine orders them oldest first. */
|
|
673
|
+
async listAnalyticsObservations(scopeId, jobId) {
|
|
674
|
+
const list = await read("analytics.list", {
|
|
675
|
+
scope: scopeOf(scopeId),
|
|
676
|
+
job_id: jobId,
|
|
677
|
+
});
|
|
678
|
+
return list.observations.map((observation) => analyticsObservationOf(observation, scopeId));
|
|
679
|
+
}
|
|
680
|
+
/** Every committed manual import in one scope, from metric_import.list. */
|
|
681
|
+
async listMetricImports(scopeId) {
|
|
682
|
+
const list = await read("metric_import.list", { scope: scopeOf(scopeId) });
|
|
683
|
+
return list.imports.map((record) => metricImportOf(record));
|
|
684
|
+
}
|
|
685
|
+
// ---- Store health (read only) ---------------------------------------------------------------
|
|
686
|
+
// The desktop never calls heartbeat.beat, store.backup or store.restore. The engine reads the clock only from `now`.
|
|
687
|
+
/** The heartbeat record's state at `nowUtc`, from heartbeat.status. Ages and states are the engine's. */
|
|
688
|
+
async heartbeatStatus(nowUtc, staleAfterSecs) {
|
|
689
|
+
const engine = await read("heartbeat.status", {
|
|
690
|
+
now: nowUtc,
|
|
691
|
+
stale_after_secs: staleAfterSecs,
|
|
692
|
+
});
|
|
693
|
+
return heartbeatStatusOf(engine);
|
|
694
|
+
}
|
|
695
|
+
/** Every restore receipt, newest first, from store.restore_receipts. */
|
|
696
|
+
async listRestoreReceipts() {
|
|
697
|
+
const engine = await read("store.restore_receipts", {});
|
|
698
|
+
return restoreReceiptListOf(engine);
|
|
699
|
+
}
|
|
700
|
+
// ---- Webhook inbox (read only) ---------------------------------------------------------------
|
|
701
|
+
// The desktop reads the events the daemon stored. It never receives an event, sets its state or tests a signature.
|
|
702
|
+
/** The newest page of events of one channel account, or of the unmatched events when scopeId is null. */
|
|
703
|
+
async listInbox(scopeId) {
|
|
704
|
+
const params = scopeId === null ? { unmatched: true, limit: INBOX_PAGE } : { scope: scopeOf(scopeId), limit: INBOX_PAGE };
|
|
705
|
+
const list = await read("inbox.list", params);
|
|
706
|
+
return {
|
|
707
|
+
rows: list.events.map((event) => rowOf(event, () => inboxEventOf(event))),
|
|
708
|
+
moreAvailable: list.next_before !== null,
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
/** The raw item of one event. Null when the engine holds no event with this id in the scope (not_found). */
|
|
712
|
+
async getInboxEvent(scopeId, eventId) {
|
|
713
|
+
const params = scopeId === null ? { event_id: eventId } : { event_id: eventId, scope: scopeOf(scopeId) };
|
|
714
|
+
const reply = await engineCall("inbox.get", params);
|
|
715
|
+
if (!reply.ok) {
|
|
716
|
+
if (reply.error.code === "not_found")
|
|
717
|
+
return null;
|
|
718
|
+
throw new EngineReadError(reply.error.code, `inbox.get: ${reply.error.message}`);
|
|
719
|
+
}
|
|
720
|
+
return inboxRawOf(reply.value);
|
|
721
|
+
}
|
|
722
|
+
/** Event and delivery counts from inbox.stats. The engine takes no input and keeps no per-channel refusal count. */
|
|
723
|
+
async inboxStats() {
|
|
724
|
+
return inboxStatsOf(await read("inbox.stats", {}));
|
|
725
|
+
}
|
|
726
|
+
// ---- Publish pacing (read only) --------------------------------------------------------------
|
|
727
|
+
// publish.next and publish.quota_get decide and report; the desktop never queues, runs or changes a limit.
|
|
728
|
+
/** publish.next for every account of one channel, decided at the browser clock. Every row is kept. */
|
|
729
|
+
async listPublishNext(scopeId) {
|
|
730
|
+
const scope = scopeOf(scopeId);
|
|
731
|
+
const reply = await read("publish.next", {
|
|
732
|
+
scope: { venture: scope.venture, channel: scope.channel },
|
|
733
|
+
now: this.nowUtc(),
|
|
734
|
+
});
|
|
735
|
+
return publishNextOf(reply);
|
|
736
|
+
}
|
|
737
|
+
/** publish.quota_get for one account, read at the browser clock. A failed read throws; the screen shows it for that account. */
|
|
738
|
+
async getPublishQuota(scopeId, platform, remoteAccount) {
|
|
739
|
+
const record = await read("publish.quota_get", {
|
|
740
|
+
scope: scopeOf(scopeId),
|
|
741
|
+
platform,
|
|
742
|
+
remote_account: remoteAccount,
|
|
743
|
+
now: this.nowUtc(),
|
|
744
|
+
});
|
|
745
|
+
return publishQuotaOf(record);
|
|
746
|
+
}
|
|
747
|
+
// ---- Internal-------------------------------------------------------------------------------
|
|
748
|
+
/** An accept or dismiss: the engine's reply as a Result. Revision and state errors come back as typed codes. */
|
|
749
|
+
async decideProposal(method, scopeId, proposalId, expectedRevision) {
|
|
750
|
+
const scope = parseScopeKey(scopeId);
|
|
751
|
+
if (!scope)
|
|
752
|
+
return failed({ code: "not_found", message: `No channel ${scopeId} in the engine.` });
|
|
753
|
+
const reply = await engineCall(method, {
|
|
754
|
+
scope,
|
|
755
|
+
proposal_id: proposalId,
|
|
756
|
+
expected_revision: expectedRevision,
|
|
757
|
+
now: this.nowUtc(),
|
|
758
|
+
});
|
|
759
|
+
if (!reply.ok)
|
|
760
|
+
return failed(dataErrorFrom(reply.error));
|
|
761
|
+
try {
|
|
762
|
+
return { ok: true, value: proposalOf(reply.value) };
|
|
763
|
+
}
|
|
764
|
+
catch (error) {
|
|
765
|
+
return failed({ code: "engine_error", message: messageOf(error), engineCode: "engine_mapping" });
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
/** Every stored channel's scope, from channel.list. Scope is the only key the engine has. */
|
|
769
|
+
async scopes() {
|
|
770
|
+
const list = await read("channel.list", {});
|
|
771
|
+
return list.channels.map((row) => row.scope);
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* The publish jobs in one scope, from publish.list. Reads run in every engine role. Any engine error
|
|
775
|
+
* throws, so the screen shows its load error rather than an empty list.
|
|
776
|
+
*/
|
|
777
|
+
async publishJobsFor(scope) {
|
|
778
|
+
const list = await read("publish.list", { scope });
|
|
779
|
+
return list.jobs;
|
|
780
|
+
}
|
|
781
|
+
/** An account write's reply: the stored account as an Account, or the mapped engine error. */
|
|
782
|
+
accountResult(reply) {
|
|
783
|
+
if (!reply.ok)
|
|
784
|
+
return failed(dataErrorFrom(reply.error));
|
|
785
|
+
try {
|
|
786
|
+
return { ok: true, value: accountOf(reply.value, this.nowUtc()) };
|
|
787
|
+
}
|
|
788
|
+
catch (error) {
|
|
789
|
+
return failed({ code: "engine_error", message: messageOf(error), engineCode: "engine_mapping" });
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
/** A draft write's reply: the stored draft as a ManualDraft, or the mapped engine error. */
|
|
793
|
+
draftResult(reply) {
|
|
794
|
+
if (!reply.ok)
|
|
795
|
+
return failed(dataErrorFrom(reply.error));
|
|
796
|
+
try {
|
|
797
|
+
return { ok: true, value: draftOf(reply.value) };
|
|
798
|
+
}
|
|
799
|
+
catch (error) {
|
|
800
|
+
return failed({ code: "engine_error", message: messageOf(error), engineCode: "engine_mapping" });
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
/** A calendar write's reply: the stored entry as a job, or the mapped engine error. */
|
|
804
|
+
async entryResult(reply, scope, entryId) {
|
|
805
|
+
if (!reply.ok)
|
|
806
|
+
return this.failure(reply.error, scope, entryId);
|
|
807
|
+
try {
|
|
808
|
+
return { ok: true, value: entryToJob(reply.value) };
|
|
809
|
+
}
|
|
810
|
+
catch (error) {
|
|
811
|
+
return failed({ code: "engine_error", message: messageOf(error), engineCode: "engine_mapping" });
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
/** The mapped error for a failed call. A revision conflict reads the current revision from the engine. */
|
|
815
|
+
async failure(error, scope, entryId) {
|
|
816
|
+
const mapped = dataErrorFrom(error);
|
|
817
|
+
if (mapped.code !== "revision_conflict")
|
|
818
|
+
return failed(mapped);
|
|
819
|
+
const now = await engineCall("calendar.get", { scope, entry_id: entryId });
|
|
820
|
+
return failed({ ...mapped, currentRevision: now.ok ? now.value.revision : undefined });
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
/** Reads throw on any engine error, so the screen shows its load error state. */
|
|
824
|
+
async function read(method, params) {
|
|
825
|
+
const reply = await engineCall(method, params);
|
|
826
|
+
if (!reply.ok)
|
|
827
|
+
throw new EngineReadError(reply.error.code, `${method}: ${reply.error.message}`);
|
|
828
|
+
return reply.value;
|
|
829
|
+
}
|
|
830
|
+
/** A scope id as the engine's scope. A malformed id is a read error, not a silent empty result. */
|
|
831
|
+
function scopeOf(id) {
|
|
832
|
+
const scope = parseScopeKey(id);
|
|
833
|
+
if (!scope)
|
|
834
|
+
throw new EngineReadError("not_found", `No channel ${id} in the engine.`);
|
|
835
|
+
return scope;
|
|
836
|
+
}
|