@lorekit/cli 1.50.0 → 1.51.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/package.json +1 -1
- package/src/mcp.mjs +30 -0
- package/src/store/format.mjs +1 -1
- package/src/store/local.mjs +1 -1
- package/src/store/remote.mjs +285 -2
package/package.json
CHANGED
package/src/mcp.mjs
CHANGED
|
@@ -158,6 +158,30 @@ export function normalizeRunEnvironment(raw) {
|
|
|
158
158
|
return /^[A-Za-z0-9_.\-:]+$/.test(t) ? t : null;
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
+
/**
|
|
162
|
+
* The retry delay a failed response advertised, in whole seconds, or null.
|
|
163
|
+
*
|
|
164
|
+
* Prefers the JSON body's `retryAfterSeconds` over the `Retry-After` header:
|
|
165
|
+
* both are set by the same `tooManyRequests()` helper, and the body value is
|
|
166
|
+
* the number the rate-limit RPC actually returned, while the header is its
|
|
167
|
+
* stringified copy that an intermediary may rewrite.
|
|
168
|
+
*
|
|
169
|
+
* TOTAL over any input. `headers` is read through optional calls because a
|
|
170
|
+
* test double (and a hand-rolled Response-alike) may not implement the Headers
|
|
171
|
+
* interface, and a missing retry hint must never be able to throw on an error
|
|
172
|
+
* path — the caller is already handling a failure.
|
|
173
|
+
*/
|
|
174
|
+
export function retryAfterFrom(data, headers) {
|
|
175
|
+
const raw = data?.retryAfterSeconds ?? (typeof headers?.get === 'function' ? headers.get('retry-after') : null);
|
|
176
|
+
if (raw == null || raw === '') return null;
|
|
177
|
+
const n = Number(raw);
|
|
178
|
+
// A `Retry-After` may also be an HTTP-date; a non-numeric value is reported
|
|
179
|
+
// as "no hint" so the caller falls back to its own backoff rather than
|
|
180
|
+
// waiting on NaN.
|
|
181
|
+
if (!Number.isFinite(n) || n < 0) return null;
|
|
182
|
+
return Math.ceil(n);
|
|
183
|
+
}
|
|
184
|
+
|
|
161
185
|
export async function restFetch(baseUrl, token, path, { method = 'GET', body, timeoutMs = 10000, traceparent } = {}) {
|
|
162
186
|
const controller = new AbortController();
|
|
163
187
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
@@ -203,6 +227,12 @@ export async function restFetch(baseUrl, token, path, { method = 'GET', body, ti
|
|
|
203
227
|
return {
|
|
204
228
|
ok: false,
|
|
205
229
|
httpStatus: res.status,
|
|
230
|
+
// How long the server asked the caller to wait, in seconds, or null when
|
|
231
|
+
// it did not say. Only a 429 carries one today (`tooManyRequests` sets
|
|
232
|
+
// BOTH a `retryAfterSeconds` body field and the `Retry-After` header),
|
|
233
|
+
// but this is read on every failure so a future 503 needs no change
|
|
234
|
+
// here. Additive: existing callers ignore the extra key.
|
|
235
|
+
retryAfter: retryAfterFrom(data, res.headers),
|
|
206
236
|
error: data?.error ? { message: data.error, code: data.code } : { code: res.status, message: text.slice(0, 200) || res.statusText },
|
|
207
237
|
};
|
|
208
238
|
}
|
package/src/store/format.mjs
CHANGED
|
@@ -29,7 +29,7 @@ export const FIELDS = [
|
|
|
29
29
|
// columns: a file written before this existed simply decodes it as absent.
|
|
30
30
|
'expires_at',
|
|
31
31
|
// Recurrence — how many times this lesson has been written, mirroring the
|
|
32
|
-
// hosted `memories.seen_count` column (migration
|
|
32
|
+
// hosted `memories.seen_count` column (migration 00059) so an offline store
|
|
33
33
|
// carries the same salience signal a remote one does. Appended like the
|
|
34
34
|
// columns above: a file written before this existed decodes it as absent,
|
|
35
35
|
// which the read projection reports as 0 rather than inventing a count.
|
package/src/store/local.mjs
CHANGED
|
@@ -134,7 +134,7 @@ class LocalStore {
|
|
|
134
134
|
archived_at: null,
|
|
135
135
|
expires_at,
|
|
136
136
|
// Recurrence, counted the way the hosted `memory_write` RPC counts it
|
|
137
|
-
// (migration
|
|
137
|
+
// (migration 00059): a write against a key this store already holds IS
|
|
138
138
|
// the next sighting. `seenCountOf` floors an absent/hand-edited value to
|
|
139
139
|
// 0, so a file written before this column existed resumes at 1 on its
|
|
140
140
|
// next write rather than throwing or restarting the tally at 2.
|
package/src/store/remote.mjs
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
import { restFetch, mcpToRestBase } from '../mcp.mjs';
|
|
22
22
|
import { getActiveTraceparent } from '../telemetry.mjs';
|
|
23
23
|
import { withReadFields } from './entry-fields.mjs';
|
|
24
|
+
import { normalizeCreatedAt } from './created-at.mjs';
|
|
24
25
|
|
|
25
26
|
// Drop undefined/null args so JSON payloads stay tidy.
|
|
26
27
|
function stripUndefined(obj) {
|
|
@@ -29,6 +30,55 @@ function stripUndefined(obj) {
|
|
|
29
30
|
return out;
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
/**
|
|
34
|
+
* A read that could not be answered — a transport failure or a non-2xx status,
|
|
35
|
+
* as opposed to "the lesson is not there".
|
|
36
|
+
*
|
|
37
|
+
* Exported so a caller can tell it from a programming error and degrade
|
|
38
|
+
* per-entry (report this one, keep going) instead of aborting a whole run.
|
|
39
|
+
* `result` carries the raw store envelope for the message the caller shows.
|
|
40
|
+
*/
|
|
41
|
+
export class StoreReadError extends Error {
|
|
42
|
+
constructor(message, result) {
|
|
43
|
+
super(message);
|
|
44
|
+
this.name = 'StoreReadError';
|
|
45
|
+
this.result = result;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// An absolute `expires_at` expressed as the hosted write's relative `ttl_days`.
|
|
50
|
+
//
|
|
51
|
+
// Three outcomes, and the third exists because "no expiry" and "I cannot tell"
|
|
52
|
+
// must not collapse into one answer:
|
|
53
|
+
//
|
|
54
|
+
// `undefined` no expiry — the caller states that positively with
|
|
55
|
+
// `clear_ttl: true`, so a permanent lesson stops being expiring.
|
|
56
|
+
// `'expired'` already elapsed; the caller must refuse (see `putEntry`).
|
|
57
|
+
// `'unknown'` an unparseable value. The caller then sends NEITHER TTL field,
|
|
58
|
+
// leaving the RPC on its `'keep'` branch, because the safe
|
|
59
|
+
// reading of a corrupt frontmatter field is "do not touch the
|
|
60
|
+
// expiry" — the same fail-safe posture as `isExpired`. Treating
|
|
61
|
+
// it as no expiry would let one bad character wipe a live remote
|
|
62
|
+
// TTL.
|
|
63
|
+
//
|
|
64
|
+
// else the remaining WHOLE days, clamped to the schema's 1–365.
|
|
65
|
+
function remoteTtlDays(expiresAt, now = new Date()) {
|
|
66
|
+
const exact = remoteTtlDaysExact(expiresAt, now);
|
|
67
|
+
if (typeof exact !== 'number') return exact;
|
|
68
|
+
return Math.min(365, Math.max(1, exact));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// The same conversion WITHOUT the 1–365 clamp, so a caller can see that the
|
|
72
|
+
// clamp bound and report the loss. Same three non-numeric outcomes.
|
|
73
|
+
function remoteTtlDaysExact(expiresAt, now = new Date()) {
|
|
74
|
+
if (!expiresAt) return undefined;
|
|
75
|
+
const ms = Date.parse(expiresAt);
|
|
76
|
+
if (Number.isNaN(ms)) return 'unknown';
|
|
77
|
+
const remaining = ms - now.getTime();
|
|
78
|
+
if (remaining <= 0) return 'expired';
|
|
79
|
+
return Math.ceil(remaining / 86_400_000);
|
|
80
|
+
}
|
|
81
|
+
|
|
32
82
|
export function createRemoteStore({ endpoint, token } = {}) {
|
|
33
83
|
return new RemoteStore(endpoint, token);
|
|
34
84
|
}
|
|
@@ -151,7 +201,24 @@ class RemoteStore {
|
|
|
151
201
|
// scope+key is unique, so one row is all there can be — don't pull the default page of 50.
|
|
152
202
|
p.set('limit', '1');
|
|
153
203
|
const res = await this._rest(`/memories?${p}`);
|
|
154
|
-
|
|
204
|
+
// `unusable` is passed through: `_rest` short-circuits an unconfigured
|
|
205
|
+
// store with that flag and NOTHING else, so a caller that drops it is left
|
|
206
|
+
// with a failure carrying no error and no networkError — a blank failure it
|
|
207
|
+
// can only report generically. Additive; every existing caller branches on
|
|
208
|
+
// `ok` and ignores the extra key.
|
|
209
|
+
if (!res.ok) {
|
|
210
|
+
return {
|
|
211
|
+
ok: false,
|
|
212
|
+
error: res.error ?? null,
|
|
213
|
+
// Carried for the same reason `write` carries them: a read can be
|
|
214
|
+
// rate-limited too, and a caller that retries needs to tell a 429 it
|
|
215
|
+
// should wait out from one it must not.
|
|
216
|
+
httpStatus: res.httpStatus ?? null,
|
|
217
|
+
retryAfter: res.retryAfter ?? null,
|
|
218
|
+
networkError: res.networkError ?? null,
|
|
219
|
+
unusable: res.unusable ?? false,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
155
222
|
const entries = res.data?.entries ?? [];
|
|
156
223
|
// Same projection as list/search — a single read must not answer with a
|
|
157
224
|
// different shape than the listing the caller found the key in.
|
|
@@ -181,7 +248,223 @@ class RemoteStore {
|
|
|
181
248
|
if (origin_commit !== undefined) body.origin_commit = origin_commit;
|
|
182
249
|
if (origin_pr !== undefined) body.origin_pr = origin_pr;
|
|
183
250
|
const res = await this._rest('/memories', { method: 'POST', body });
|
|
184
|
-
|
|
251
|
+
// `httpStatus` and `retryAfter` are passed through so a caller can tell the
|
|
252
|
+
// two 429s apart and honour the server's own backoff. They are DIFFERENT
|
|
253
|
+
// failures wearing one status code: `code: 'rate_limited'` is transient and
|
|
254
|
+
// must be retried, `code: 'memory_cap'` is terminal (translateDbError maps
|
|
255
|
+
// the LK001 cap trigger to 429 as well) and must not be. Additive — the
|
|
256
|
+
// existing `{ ok, error, networkError }` keys are unchanged.
|
|
257
|
+
// Every field is coalesced, not just the retry hint: a caller comparing
|
|
258
|
+
// `httpStatus` must not get `null` from a refusal and `undefined` from the
|
|
259
|
+
// network-error or `unusable` branch, which is the exact split the shape
|
|
260
|
+
// exists to remove.
|
|
261
|
+
return {
|
|
262
|
+
ok: res.ok,
|
|
263
|
+
error: res.error ?? null,
|
|
264
|
+
httpStatus: res.httpStatus ?? null,
|
|
265
|
+
retryAfter: res.retryAfter ?? null,
|
|
266
|
+
networkError: res.networkError ?? null,
|
|
267
|
+
// `_rest` short-circuits an unconfigured store with `{ ok:false,
|
|
268
|
+
// unusable:true }` and no error at all, so without this the caller sees
|
|
269
|
+
// a failure with every field null and no reason. Passed through the way
|
|
270
|
+
// `listScopes` already does.
|
|
271
|
+
unusable: res.unusable ?? false,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ── Migrate-destination parity with LocalStore ────────────────────────────
|
|
276
|
+
//
|
|
277
|
+
// `migrate` classifies each source entry ADD / UPDATE / NOOP with a read and
|
|
278
|
+
// then upserts it, against whatever store it was handed. LocalStore answers
|
|
279
|
+
// that with `getEntry` + `putEntry`; these are the remote halves, so the
|
|
280
|
+
// migrate loop stays ONE code path instead of branching per destination.
|
|
281
|
+
//
|
|
282
|
+
// The local pair is lossless (`putEntry` writes every field verbatim,
|
|
283
|
+
// archived rows included). The remote pair CANNOT be, because the hosted
|
|
284
|
+
// write is an RPC with a fixed parameter list, not a file write:
|
|
285
|
+
//
|
|
286
|
+
// preserved scope, key, source_agent, trigger, and `created` —
|
|
287
|
+
// sent as `created_at`, which memory_write honours on INSERT
|
|
288
|
+
// only, so a migrated lesson keeps its original creation date
|
|
289
|
+
// and its ranking recency with it. `value` survives too but is
|
|
290
|
+
// TRIMMED: `MemoryWriteSchema` applies `.transform(s =>
|
|
291
|
+
// s.trim())`, so surrounding whitespace does not make the trip.
|
|
292
|
+
// re-stamped `updated` — the server sets it to the write instant. There is
|
|
293
|
+
// no parameter for it, and inventing one would let a client
|
|
294
|
+
// backdate an edit it did not make.
|
|
295
|
+
// derived `seen_count` — the RPC owns the tally (migration 00059: a
|
|
296
|
+
// write against an existing key IS the next sighting). A lesson
|
|
297
|
+
// the hosted store has never seen lands at 1; one it already
|
|
298
|
+
// holds lands at ITS count plus one, not the local one. Either
|
|
299
|
+
// way the local history does not transfer.
|
|
300
|
+
// converted `expires_at` → `ttl_days`, the remaining whole days, clamped
|
|
301
|
+
// to the schema's 1–365 (a longer-lived TTL is clamped, not
|
|
302
|
+
// dropped — the alternative is silently making it permanent).
|
|
303
|
+
// A clamp IS lossy, so it is reported: the result carries
|
|
304
|
+
// `ttlClamped: true` and the caller can list the entry as
|
|
305
|
+
// shortened rather than leaving the user to discover it.
|
|
306
|
+
// A PERMANENT entry sends `clear_ttl: true` rather than simply
|
|
307
|
+
// omitting `ttl_days`: omission is the RPC's `'keep'` branch
|
|
308
|
+
// (migration 00031), which leaves an existing remote
|
|
309
|
+
// `expires_at` in place, so a permanent local lesson would
|
|
310
|
+
// land on an expiring remote row and still die.
|
|
311
|
+
// authoritative
|
|
312
|
+
// `tags`. The conflict clause is `tags = excluded.tags`, so the
|
|
313
|
+
// source entry's list REPLACES the hosted one — an untagged
|
|
314
|
+
// local entry sends `[]` and clears whatever labels the hosted
|
|
315
|
+
// row carried. That is what a verbatim upsert means here (the
|
|
316
|
+
// local file is the thing being migrated), but it is the one
|
|
317
|
+
// field where "verbatim" can remove hosted data, so it is
|
|
318
|
+
// called out rather than filed under preserved.
|
|
319
|
+
// sticky `origin_*`. The RPC's conflict clause coalesces provenance
|
|
320
|
+
// (`coalesce(excluded.origin_repo, memories.origin_repo)`, and
|
|
321
|
+
// likewise for branch/commit/pr) so a write that does not know
|
|
322
|
+
// a field cannot erase what an earlier one recorded. A migrated
|
|
323
|
+
// entry with NO provenance therefore leaves whatever the hosted
|
|
324
|
+
// row already had; it cannot clear it, by design, and there is
|
|
325
|
+
// no parameter that would. `source_agent` and `trigger` are NOT
|
|
326
|
+
// coalesced (`= excluded.*`), so an absent one still CLEARS the
|
|
327
|
+
// hosted value — not because a null is sent (`stripUndefined`
|
|
328
|
+
// drops nulls before the request) but because the REST handler
|
|
329
|
+
// substitutes `?? null` for the missing field and the RPC
|
|
330
|
+
// writes that. Omitted and null are the same instruction here,
|
|
331
|
+
// which is the opposite of what they mean for `origin_*`. `kind` and `host` are coalesced the same
|
|
332
|
+
// way, and this store never sends them at all — the server
|
|
333
|
+
// infers both from the `loop::` tag (`resolveKindHost`), which
|
|
334
|
+
// the tags carry, so a migrated lesson classifies itself.
|
|
335
|
+
//
|
|
336
|
+
// Two states have no remote representation at all and are REFUSED rather
|
|
337
|
+
// than silently rewritten, because writing them would resurrect a lesson the
|
|
338
|
+
// user retired: an archived entry (every conflict predicate on `memories` is
|
|
339
|
+
// partial on `archived_at is null`, so the hosted write does not revive the
|
|
340
|
+
// archived row — it INSERTS a second, live one beside it, leaving the store
|
|
341
|
+
// with both) and
|
|
342
|
+
// an already-expired one (any `ttl_days` re-dates it into the future). Both
|
|
343
|
+
// come back as `{ ok:false, unsupported }` so the caller can report them as
|
|
344
|
+
// skipped. `migrate` does NOT filter them today — adding that filter is the
|
|
345
|
+
// job of the PR that makes a remote destination reachable — so until then
|
|
346
|
+
// this refusal is the only thing standing between an archived lesson and
|
|
347
|
+
// resurrection, which is why it lives in the store and not in the caller.
|
|
348
|
+
|
|
349
|
+
// Raw lookup by scope+key, mirroring `LocalStore.getEntry` — the entry or
|
|
350
|
+
// null. The ROW is each store's own: this answers a REST `MemoryEntry`
|
|
351
|
+
// (`created_at`/`updated_at`) and the local one answers parsed frontmatter
|
|
352
|
+
// (`created`/`updated`), both through `withReadFields`. That is deliberate —
|
|
353
|
+
// the pair exists so a caller can ASK either store whether a key is there,
|
|
354
|
+
// not so it can compare two rows field-by-field without knowing which store
|
|
355
|
+
// produced them. A caller that compares has to speak the destination's
|
|
356
|
+
// spelling; that is what the remote comparison in the migrate loop does. LocalStore's is synchronous and this one cannot be, so callers must
|
|
357
|
+
// `await` it; awaiting the local store's plain return value is a no-op.
|
|
358
|
+
//
|
|
359
|
+
// One semantic difference the caller has to know about: LocalStore.getEntry
|
|
360
|
+
// sees archived rows and this cannot — `GET /memories` filters them out — so
|
|
361
|
+
// a remote destination classifies an archived counterpart as ADD, and the
|
|
362
|
+
// write then lands as a NEW live row beside the archived one (the conflict
|
|
363
|
+
// predicates are partial on `archived_at is null`). That is what the hosted
|
|
364
|
+
// `memory_write` does for any write against an archived key, not a
|
|
365
|
+
// migrate-specific quirk — and it is why `putEntry` refuses an archived
|
|
366
|
+
// source entry outright rather than relying on this classification.
|
|
367
|
+
//
|
|
368
|
+
// A FAILED read THROWS rather than answering null. Null is the answer to "no
|
|
369
|
+
// such lesson", and a caller that classifies ADD / UPDATE / NOOP acts on it:
|
|
370
|
+
// returning null for a transient 500 or a dropped connection would quietly
|
|
371
|
+
// reclassify an existing hosted lesson as new and overwrite it. The local
|
|
372
|
+
// store never throws here (a file read that fails is genuinely a miss), so
|
|
373
|
+
// this widens the contract only where the failure mode exists.
|
|
374
|
+
async getEntry({ scope, key } = {}) {
|
|
375
|
+
const res = await this.read({ scope, key });
|
|
376
|
+
if (!res.ok) {
|
|
377
|
+
// An unconfigured store carries no error at all, so name that case
|
|
378
|
+
// rather than reporting the generic failure text for it.
|
|
379
|
+
const reason = res.unusable
|
|
380
|
+
? 'the remote store is not configured (missing endpoint or token)'
|
|
381
|
+
: (res.networkError || res.error?.message || 'read failed');
|
|
382
|
+
throw new StoreReadError(`remote read failed for ${scope}::${key}: ${reason}`, res);
|
|
383
|
+
}
|
|
384
|
+
return res.entry ?? null;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Upsert one entry, as close to verbatim as the hosted write allows. See the
|
|
388
|
+
// fidelity table above for exactly which fields survive.
|
|
389
|
+
//
|
|
390
|
+
// EVERY branch answers with the SAME key set — `write`'s
|
|
391
|
+
// `{ ok, error, httpStatus, retryAfter, networkError, unusable }` plus
|
|
392
|
+
// Unlike `LocalStore.putEntry`, no `entry` comes back: the hosted write
|
|
393
|
+
// returns an id and derives the rest server-side, so echoing the request as
|
|
394
|
+
// if it were the stored row would be a fabrication. A caller that needs the
|
|
395
|
+
// stored row reads it back.
|
|
396
|
+
//
|
|
397
|
+
// `unsupported` (the refusal reason, else null), `ttlClamped` (the entry
|
|
398
|
+
// landed with a shortened life) and `createdAtDropped` (its `created` was
|
|
399
|
+
// unusable, so the server stamped the write instant instead). The last two
|
|
400
|
+
// report what HAPPENED, so both are false when the write did not succeed. A refusal fills the transport fields
|
|
401
|
+
// with null rather than omitting them, so a caller reading any one key never
|
|
402
|
+
// gets a value from one branch and `undefined` from another.
|
|
403
|
+
async putEntry(entry = {}, { now = new Date() } = {}) {
|
|
404
|
+
const refuse = (unsupported, message) => ({
|
|
405
|
+
ok: false,
|
|
406
|
+
unsupported,
|
|
407
|
+
// Carries a `code` like every real failure, so a caller can branch on
|
|
408
|
+
// one field instead of matching prose.
|
|
409
|
+
error: { message, code: 'unsupported' },
|
|
410
|
+
httpStatus: null,
|
|
411
|
+
retryAfter: null,
|
|
412
|
+
networkError: null,
|
|
413
|
+
// Every key any putEntry branch answers with, so a caller reading one
|
|
414
|
+
// never gets a value from one branch and `undefined` from another.
|
|
415
|
+
unusable: false,
|
|
416
|
+
ttlClamped: false,
|
|
417
|
+
createdAtDropped: false,
|
|
418
|
+
});
|
|
419
|
+
if (entry?.archived_at) {
|
|
420
|
+
return refuse('archived', 'archived entries cannot be written remotely — the hosted write would insert a second, live row beside the archived one');
|
|
421
|
+
}
|
|
422
|
+
const ttl = remoteTtlDays(entry?.expires_at, now);
|
|
423
|
+
if (ttl === 'expired') {
|
|
424
|
+
return refuse('expired', 'expired entries cannot be written remotely — any TTL would re-date them into the future');
|
|
425
|
+
}
|
|
426
|
+
// The same validation the server applies (`_shared/created-at.ts`, mirrored
|
|
427
|
+
// here), run BEFORE the request rather than discovered as a 400. A local
|
|
428
|
+
// file can hold a hand-edited or clock-skewed `created`, and losing the
|
|
429
|
+
// whole lesson over its creation date is the wrong trade: drop the
|
|
430
|
+
// override, let the server stamp now, and report the loss so the caller
|
|
431
|
+
// can say which entries were re-dated.
|
|
432
|
+
let createdAt;
|
|
433
|
+
let createdAtDropped = false;
|
|
434
|
+
try {
|
|
435
|
+
createdAt = normalizeCreatedAt(entry?.created ?? null, now) ?? undefined;
|
|
436
|
+
} catch {
|
|
437
|
+
createdAt = undefined;
|
|
438
|
+
createdAtDropped = Boolean(entry?.created);
|
|
439
|
+
}
|
|
440
|
+
const result = await this.write(stripUndefined({
|
|
441
|
+
scope: entry.scope,
|
|
442
|
+
key: entry.key,
|
|
443
|
+
value: entry.value == null ? '' : String(entry.value),
|
|
444
|
+
tags: Array.isArray(entry.tags) ? entry.tags : [],
|
|
445
|
+
source_agent: entry.source_agent,
|
|
446
|
+
trigger: entry.trigger,
|
|
447
|
+
created_at: createdAt,
|
|
448
|
+
// `'unknown'` sends neither field, leaving the RPC on its `'keep'`
|
|
449
|
+
// branch. A real TTL sends only `ttl_days`; no TTL says so explicitly
|
|
450
|
+
// with `clear_ttl` rather than by omission — see the fidelity note above.
|
|
451
|
+
ttl_days: typeof ttl === 'number' ? ttl : undefined,
|
|
452
|
+
clear_ttl: ttl === undefined ? true : undefined,
|
|
453
|
+
origin_repo: entry.origin_repo,
|
|
454
|
+
origin_branch: entry.origin_branch,
|
|
455
|
+
origin_commit: entry.origin_commit,
|
|
456
|
+
origin_pr: entry.origin_pr,
|
|
457
|
+
}));
|
|
458
|
+
// Always present, like every other key in this envelope — a caller must
|
|
459
|
+
// not have to know which branch produced the result to read it.
|
|
460
|
+
// Both flags describe what HAPPENED, so they are false on a write that did
|
|
461
|
+
// not happen — a failed request shortened nothing and re-dated nothing.
|
|
462
|
+
return {
|
|
463
|
+
...result,
|
|
464
|
+
unsupported: null,
|
|
465
|
+
ttlClamped: Boolean(result.ok) && ttl === 365 && remoteTtlDaysExact(entry?.expires_at, now) > 365,
|
|
466
|
+
createdAtDropped: Boolean(result.ok) && createdAtDropped,
|
|
467
|
+
};
|
|
185
468
|
}
|
|
186
469
|
|
|
187
470
|
// Natural-key DELETE. Without `force` the server soft-archives (stamps
|