caracms 0.2.0 → 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.
Files changed (50) hide show
  1. package/README.md +247 -3
  2. package/bin/caracms.mjs +32 -16
  3. package/bin/cloudflare-ips.mjs +38 -0
  4. package/bin/gate.mjs +277 -4
  5. package/bin/init.mjs +64 -20
  6. package/package.json +1 -1
  7. package/skills/caracms-adopt/SKILL.md +5 -3
  8. package/skills/caracms-adopt/look-same.mjs +8 -4
  9. package/skills/caracms-blocks/SKILL.md +20 -2
  10. package/skills/caracms-tina/SKILL.md +14 -10
  11. package/src/components/Head.astro +3 -2
  12. package/src/content.ts +2 -0
  13. package/src/editor/client/editor.ts +24 -6
  14. package/src/editor/client/live/publish.tsx +20 -10
  15. package/src/editor/client/pane/parts.tsx +34 -2
  16. package/src/editor/client/state/keeper.ts +51 -6
  17. package/src/editor/client/state/live.ts +45 -6
  18. package/src/editor/middleware.ts +3 -2
  19. package/src/editor/routes/try-again.ts +2 -1
  20. package/src/editor/server/admin-props.ts +2 -1
  21. package/src/editor/server/mode.ts +13 -3
  22. package/src/editor/styles/levels/live.css +1 -1
  23. package/src/editor/types.ts +25 -6
  24. package/src/files-route.ts +9 -4
  25. package/src/forms/endpoint.ts +11 -16
  26. package/src/head.ts +10 -1
  27. package/src/index.ts +53 -7
  28. package/src/live/README.md +4 -2
  29. package/src/live/auth/auth.ts +4 -1
  30. package/src/live/auth/guard.ts +3 -2
  31. package/src/live/auth/index.ts +6 -7
  32. package/src/live/auth/login.ts +17 -8
  33. package/src/live/auth/middleware.ts +7 -3
  34. package/src/live/auth/request.ts +92 -11
  35. package/src/live/auth/views.ts +9 -4
  36. package/src/live/autosaves.ts +59 -16
  37. package/src/live/backend.ts +13 -1
  38. package/src/live/drafts-store.ts +4 -3
  39. package/src/live/editor.ts +92 -67
  40. package/src/live/git.ts +112 -34
  41. package/src/live/github.ts +128 -13
  42. package/src/live/locks.ts +92 -26
  43. package/src/live/mode.ts +131 -18
  44. package/src/live/notices.ts +100 -0
  45. package/src/live/redis.ts +161 -39
  46. package/src/live/runtime.ts +11 -3
  47. package/src/live/status-cache.ts +219 -0
  48. package/src/live/status.ts +14 -2
  49. package/src/live/timings.ts +24 -0
  50. package/src/media.ts +26 -1
package/src/live/redis.ts CHANGED
@@ -68,10 +68,24 @@ export interface RedisClient {
68
68
  compareAndSet(key: string, expected: string | null, value: string, options?: { px?: number }): Promise<boolean>;
69
69
  /** Delete `key` only if it holds `expected`. Atomic. For releasing a lock you hold. */
70
70
  compareAndDelete(key: string, expected: string): Promise<boolean>;
71
+ /**
72
+ * Several commands sent together (Redis diet Decision 7): ioredis's pipeline, one
73
+ * round trip; the stand-in runs them in order. Replies come back in the commands'
74
+ * order. Each command is billed and counted as one. A batch that failed in flight is
75
+ * sent again only when every command in it is safe to send twice (see `connectRedis`).
76
+ * A command that needs another's answer (an MGET of what a SCAN found) goes in the next batch.
77
+ */
78
+ batch<const C extends readonly BatchCommand[]>(commands: C): Promise<BatchReplies<C>>;
71
79
  /** Close the connection, if any. The client stays usable: the next command reconnects. */
72
80
  close(): Promise<void>;
73
81
  }
74
82
 
83
+ type BatchName = Exclude<keyof RedisClient, 'batch' | 'close'>;
84
+ /** One command of a batch: its method's name and arguments, `['mget', keys]`, `['hgetall', key]`. */
85
+ export type BatchCommand = { [K in BatchName]: readonly [K, ...Parameters<RedisClient[K]>] }[BatchName];
86
+ /** The replies to a batch, as the methods of the same names give them. */
87
+ export type BatchReplies<C extends readonly BatchCommand[]> = { -readonly [I in keyof C]: C[I] extends readonly [infer K extends BatchName, ...unknown[]] ? Awaited<ReturnType<RedisClient[K]>> : never };
88
+
75
89
  /** Redis couldn't be reached within the budget, or refused a command. `code` is the real socket error's. */
76
90
  export class RedisUnavailableError extends Error {
77
91
  readonly code: string | null;
@@ -132,6 +146,86 @@ function withTimeout<T>(promise: Promise<T>, ms: number, what: string): Promise<
132
146
  }
133
147
 
134
148
  type Ioredis = InstanceType<typeof IORedis>;
149
+ /** The client or a pipeline: the same calls, which a pipeline queues. */
150
+ type Target = Record<string, (...args: unknown[]) => unknown>;
151
+
152
+ /**
153
+ * How one command goes to ioredis: whether sending it twice is harmless, the call (to the
154
+ * client, or queued on a pipeline), and how its reply reads. `send: null`: the answer is
155
+ * known without asking (`answer`), so nothing is sent.
156
+ */
157
+ interface Spec {
158
+ safe: boolean;
159
+ send: ((t: Target) => unknown) | null;
160
+ read: (c: Ioredis, reply: unknown) => unknown;
161
+ answer?: unknown;
162
+ }
163
+
164
+ const px = (ms: number) => Math.max(1, Math.round(ms));
165
+ const asIs = (_c: Ioredis, reply: unknown) => reply;
166
+ const isOne = (_c: Ioredis, reply: unknown) => reply === 1;
167
+ const known = (answer: unknown): Spec => ({ safe: true, send: null, read: asIs, answer });
168
+ const spec = (safe: boolean, name: string, args: unknown[], read: Spec['read'] = asIs): Spec => ({ safe, send: (t) => t[name]!(...args), read });
169
+
170
+ function specOf(command: BatchCommand): Spec {
171
+ switch (command[0]) {
172
+ case 'get':
173
+ case 'pttl':
174
+ case 'hgetall':
175
+ case 'smembers':
176
+ return spec(true, command[0], [command[1]]);
177
+ case 'getdel':
178
+ return spec(false, 'getdel', [command[1]]);
179
+ case 'incr':
180
+ return spec(false, 'incr', [command[1]]);
181
+ case 'mget':
182
+ return command[1].length ? spec(true, 'mget', [...command[1]]) : known([]);
183
+ case 'set': {
184
+ const [, key, value, o = {}] = command;
185
+ const args: unknown[] = [key, value];
186
+ if (o.px !== undefined) args.push('PX', px(o.px));
187
+ if (o.nx) args.push('NX');
188
+ return spec(!o.nx, 'set', args, (_c, reply) => reply === 'OK');
189
+ }
190
+ case 'del': {
191
+ const [, ...keys] = command;
192
+ return keys.length ? spec(true, 'del', keys) : known(0);
193
+ }
194
+ case 'pexpire':
195
+ return spec(true, 'pexpire', [command[1], px(command[2])], isOne);
196
+ case 'hget':
197
+ return spec(true, 'hget', [command[1], command[2]]);
198
+ case 'hset':
199
+ return spec(true, 'hset', [command[1], command[2]]);
200
+ case 'hdel':
201
+ case 'sadd':
202
+ case 'srem': {
203
+ const [name, key, ...rest] = command;
204
+ return rest.length ? spec(true, name, [key, ...rest]) : known(0);
205
+ }
206
+ case 'scan': {
207
+ const pattern = command[1];
208
+ // The first page goes with the rest of a batch; any further pages follow on the same connection.
209
+ return spec(true, 'scan', ['0', 'MATCH', pattern, 'COUNT', SCAN_COUNT], async (c, reply) => {
210
+ let [cursor, page] = reply as [string, string[]];
211
+ const keys = new Set(page);
212
+ while (cursor !== '0') {
213
+ [cursor, page] = await c.scan(cursor, 'MATCH', pattern, 'COUNT', SCAN_COUNT);
214
+ for (const k of page) keys.add(k);
215
+ }
216
+ return [...keys];
217
+ });
218
+ }
219
+ case 'compareAndSet': {
220
+ const [, key, expected, value, o = {}] = command;
221
+ return spec(false, 'eval', [CAS_SET, 1, key, expected === null ? '1' : '0', expected ?? '', value, o.px === undefined ? '' : String(px(o.px))], isOne);
222
+ }
223
+ case 'compareAndDelete':
224
+ return spec(false, 'eval', [CAS_DEL, 1, command[1], command[2]], isOne);
225
+ }
226
+ }
227
+
228
+ const SCAN_COUNT = 200;
135
229
 
136
230
  /**
137
231
  * The real client. Nothing connects until the first command, and the connection
@@ -249,7 +343,8 @@ export function connectRedis(url: string, options: RedisOptions = {}): RedisClie
249
343
  return await withTimeout(send(c), commandMs, 'Redis command');
250
344
  } catch (error) {
251
345
  const code = errorCode(error);
252
- const lost = code === 'CARACMS_TIMEOUT' || /connection is closed|stream isn't writeable|command timed out/i.test(String((error as Error)?.message));
346
+ // A pipeline answered part way: ioredis aborts the rest ("Command aborted due to connection close").
347
+ const lost = code === 'CARACMS_TIMEOUT' || /connection is closed|stream isn't writeable|command timed out|aborted due to connection close/i.test(String((error as Error)?.message));
253
348
  if (!lost) throw error; // Redis answered with an error: not ours to retry
254
349
  drop(c);
255
350
  if (!idempotent || Date.now() + backoffMs > deadline) {
@@ -264,45 +359,57 @@ export function connectRedis(url: string, options: RedisOptions = {}): RedisClie
264
359
  }
265
360
  };
266
361
 
362
+ /** One command, through `run`; one whose answer is known without asking (an MGET of no keys) isn't sent. */
363
+ const one = <T>(command: BatchCommand): Promise<T> => {
364
+ const s = specOf(command);
365
+ if (s.send === null) return Promise.resolve(s.answer as T);
366
+ return run(s.safe, async (c) => (await s.read(c, await s.send!(c as unknown as Target))) as T);
367
+ };
368
+
267
369
  return {
268
- get: (key) => run(true, (c) => c.get(key)),
269
- mget: (keys) => (keys.length ? run(true, (c) => c.mget(...keys)) : Promise.resolve([])),
270
- set: (key, value, o = {}) =>
271
- run(!o.nx, async (c) => {
272
- const args: (string | number)[] = [];
273
- if (o.px !== undefined) args.push('PX', Math.max(1, Math.round(o.px)));
274
- if (o.nx) args.push('NX');
275
- const reply = await (c.set as (...a: unknown[]) => Promise<string | null>)(key, value, ...args);
276
- return reply === 'OK';
277
- }),
278
- getdel: (key) => run(false, (c) => c.getdel(key)),
279
- del: (...keys) => (keys.length ? run(true, (c) => c.del(...keys)) : Promise.resolve(0)),
280
- incr: (key) => run(false, (c) => c.incr(key)),
281
- pexpire: (key, ms) => run(true, async (c) => (await c.pexpire(key, Math.max(1, Math.round(ms)))) === 1),
282
- pttl: (key) => run(true, (c) => c.pttl(key)),
283
- hget: (key, field) => run(true, (c) => c.hget(key, field)),
284
- hset: (key, fields) => run(true, (c) => c.hset(key, fields)),
285
- hdel: (key, ...fields) => (fields.length ? run(true, (c) => c.hdel(key, ...fields)) : Promise.resolve(0)),
286
- hgetall: (key) => run(true, (c) => c.hgetall(key)),
287
- sadd: (key, ...members) => (members.length ? run(true, (c) => c.sadd(key, ...members)) : Promise.resolve(0)),
288
- srem: (key, ...members) => (members.length ? run(true, (c) => c.srem(key, ...members)) : Promise.resolve(0)),
289
- smembers: (key) => run(true, (c) => c.smembers(key)),
290
- scan: (pattern) =>
291
- run(true, async (c) => {
292
- const keys = new Set<string>();
293
- let cursor = '0';
294
- do {
295
- const [next, batch] = await c.scan(cursor, 'MATCH', pattern, 'COUNT', 200);
296
- cursor = next;
297
- for (const k of batch) keys.add(k);
298
- } while (cursor !== '0');
299
- return [...keys];
300
- }),
301
- compareAndSet: (key, expected, value, o = {}) =>
302
- run(false, async (c) =>
303
- (await c.eval(CAS_SET, 1, key, expected === null ? '1' : '0', expected ?? '', value, o.px === undefined ? '' : String(Math.max(1, Math.round(o.px))))) === 1,
304
- ),
305
- compareAndDelete: (key, expected) => run(false, async (c) => (await c.eval(CAS_DEL, 1, key, expected)) === 1),
370
+ get: (key) => one(['get', key]),
371
+ mget: (keys) => one(['mget', keys]),
372
+ set: (key, value, o) => one(['set', key, value, o]),
373
+ getdel: (key) => one(['getdel', key]),
374
+ del: (...keys) => one(['del', ...keys]),
375
+ incr: (key) => one(['incr', key]),
376
+ pexpire: (key, ms) => one(['pexpire', key, ms]),
377
+ pttl: (key) => one(['pttl', key]),
378
+ hget: (key, field) => one(['hget', key, field]),
379
+ hset: (key, fields) => one(['hset', key, fields]),
380
+ hdel: (key, ...fields) => one(['hdel', key, ...fields]),
381
+ hgetall: (key) => one(['hgetall', key]),
382
+ sadd: (key, ...members) => one(['sadd', key, ...members]),
383
+ srem: (key, ...members) => one(['srem', key, ...members]),
384
+ smembers: (key) => one(['smembers', key]),
385
+ scan: (pattern) => one(['scan', pattern]),
386
+ compareAndSet: (key, expected, value, o) => one(['compareAndSet', key, expected, value, o]),
387
+ compareAndDelete: (key, expected) => one(['compareAndDelete', key, expected]),
388
+ // Redis diet Decision 7. ioredis answers a pipeline per command, `[error, reply]`, and
389
+ // a connection lost part way fills every unanswered one with "Connection is closed.":
390
+ // it can't say whether those ran (the command may have arrived, its answer not). So
391
+ // the batch is the unit: sent again whole when every command in it is safe to send
392
+ // twice, never when any isn't, and then it fails as a single unsafe command does.
393
+ batch: <const C extends readonly BatchCommand[]>(commands: C): Promise<BatchReplies<C>> => {
394
+ const specs = commands.map(specOf);
395
+ const sent = specs.filter((s) => s.send !== null);
396
+ if (sent.length === 0) return Promise.resolve(specs.map((s) => s.answer) as BatchReplies<C>);
397
+ return run(
398
+ sent.every((s) => s.safe),
399
+ async (c) => {
400
+ const pipeline = c.pipeline();
401
+ for (const s of sent) s.send!(pipeline as unknown as Target);
402
+ const results = await pipeline.exec();
403
+ if (!results) throw new Error('Redis gave no answer to the batch.');
404
+ const failed = results.find(([error]) => error);
405
+ if (failed) throw failed[0];
406
+ const out: unknown[] = [];
407
+ let i = 0;
408
+ for (const s of specs) out.push(s.send === null ? s.answer : await s.read(c, results[i++]![1]));
409
+ return out as BatchReplies<C>;
410
+ },
411
+ );
412
+ },
306
413
  async close() {
307
414
  if (idleTimer) clearTimeout(idleTimer);
308
415
  idleTimer = null;
@@ -527,6 +634,21 @@ export function memoryRedis(options: { now?: () => number } = {}): MemoryRedis {
527
634
  remove(key);
528
635
  return true;
529
636
  },
637
+ // Run in order, each counted; like a pipeline, every command runs and the first error is thrown after.
638
+ async batch<const C extends readonly BatchCommand[]>(commands: C): Promise<BatchReplies<C>> {
639
+ const out: unknown[] = [];
640
+ let failure: { error: unknown } | null = null;
641
+ for (const [name, ...args] of commands) {
642
+ try {
643
+ out.push(await (r[name] as (...a: unknown[]) => Promise<unknown>)(...args));
644
+ } catch (error) {
645
+ failure ??= { error };
646
+ out.push(null);
647
+ }
648
+ }
649
+ if (failure) throw failure.error;
650
+ return out as BatchReplies<C>;
651
+ },
530
652
  async close() {},
531
653
  };
532
654
  return r;
@@ -50,6 +50,8 @@ export interface RuntimeOptions {
50
50
  log?: (line: string) => void;
51
51
  runningCommit?: () => string | null;
52
52
  railwayFetch?: typeof fetch;
53
+ /** Tests: the deploy hook's fetch. */
54
+ deployHookFetch?: typeof fetch;
53
55
  }
54
56
 
55
57
  /** Build and start a runtime (not shared: tests make their own). */
@@ -69,8 +71,9 @@ export async function createLiveRuntime(options: RuntimeOptions): Promise<LiveRu
69
71
  backend = memory;
70
72
  } else {
71
73
  const tracker = new Tracker(now);
72
- const { appId, privateKey, owner, repo } = settings.github;
73
- backend = new GitHubBackend(new GitHubClient({ appId, privateKey, owner, repo, now }, tracker), settings.branches);
74
+ const gh = settings.github;
75
+ const connection = gh.kind === 'token' ? { token: gh.token, owner: gh.owner, repo: gh.repo, now } : { appId: gh.appId, privateKey: gh.privateKey, owner: gh.owner, repo: gh.repo, now };
76
+ backend = new GitHubBackend(new GitHubClient(connection, tracker), settings.branches);
74
77
  }
75
78
  }
76
79
  const { main, drafts } = backend.branches;
@@ -86,7 +89,12 @@ export async function createLiveRuntime(options: RuntimeOptions): Promise<LiveRu
86
89
  log,
87
90
  runningCommit: options.runningCommit,
88
91
  timings: settings.timings,
89
- railway: settings.railway ? { ...settings.railway, fetch: options.railwayFetch } : null,
92
+ bakedCommit: settings.bakedCommit,
93
+ redeployer: settings.deployHook
94
+ ? { kind: 'hook', url: settings.deployHook, fetch: options.deployHookFetch }
95
+ : settings.railway
96
+ ? { kind: 'railway', ...settings.railway, fetch: options.railwayFetch }
97
+ : null,
90
98
  });
91
99
  // The copy only: the startup check waits for the live editor (editor.ts), which fills
92
100
  // `commitPending` first, so a merge from main never runs ahead of pending autosaves (Decision 8).
@@ -0,0 +1,219 @@
1
+ /**
2
+ * The status poll's write-through cache (Redis diet Decisions 1–5).
3
+ *
4
+ * Every Redis write a site makes goes through its one server, so what the status route
5
+ * reads can be kept here and updated (or dropped) by the writes themselves, instead of
6
+ * being asked of Redis on every poll.
7
+ *
8
+ * - **What it holds is Redis as last read or written here, by key**: strings, hashes,
9
+ * sets, and scans (every key under a prefix, `caracms:autosave:`, with its value).
10
+ * Records, never views made from them: whether a lock holds, or which notices are a
11
+ * person's, is worked out per poll (Watch-outs).
12
+ * - **Only the status route reads it** (Decision 3). Writes, create, delete, move, the
13
+ * sweep, Publish and sign-in read Redis directly.
14
+ * - **An entry is re-read once it's `statusCacheMs` old, counted from its last Redis
15
+ * read** (Decision 1): a write-through keeps it current but doesn't make it younger, so
16
+ * what bypasses this server (the other server during a deploy, a key expiring, a hand
17
+ * edit) shows within that time (Decision 4).
18
+ * - **The writes are the modules'** (Decision 2): autosaves.ts, locks.ts, git.ts and
19
+ * notices.ts tell it each write as it lands. A write it can apply exactly (a value just
20
+ * set, a field just written) updates the entry; anything else (a compare-and-swap that
21
+ * lost, so the value is unknown) drops it. A write to an entry not held is only noted.
22
+ * - **Refreshes are single-flight** (Decision 5): a poll needing an entry another poll is
23
+ * already reading waits for that read. The ones it's missing go in one batch (SCANs
24
+ * with the rest, then their MGETs). A refresh that fails caches nothing, and nothing
25
+ * refreshes on a timer: with no poll there's no Redis read, so Redis can sleep.
26
+ * - **A write that lands while an entry is being read** may be missing from what the
27
+ * read brings back: that entry is kept for the polls waiting on it, but as already
28
+ * old, so the next poll reads it again.
29
+ */
30
+ import type { BatchCommand, RedisClient } from './redis.ts';
31
+
32
+ type Entry =
33
+ | { kind: 'string'; value: string | null; at: number }
34
+ | { kind: 'hash'; value: Map<string, string>; at: number }
35
+ | { kind: 'set'; value: Set<string>; at: number }
36
+ | { kind: 'scan'; value: Map<string, string>; at: number };
37
+
38
+ type Kind = Entry['kind'];
39
+
40
+ /** What a poll reads: keys by kind, and scans by key prefix (`caracms:lock:`). */
41
+ export interface StatusReads {
42
+ scans?: readonly string[];
43
+ hashes?: readonly string[];
44
+ sets?: readonly string[];
45
+ strings?: readonly string[];
46
+ }
47
+
48
+ /** One poll's view of the cache. Its entries are the cache's own, so the poll's later write-throughs show in it. */
49
+ export interface StatusSnapshot {
50
+ /** Every key under the prefix, with its value. */
51
+ scan(prefix: string): ReadonlyMap<string, string>;
52
+ hash(key: string): ReadonlyMap<string, string>;
53
+ set(key: string): ReadonlySet<string>;
54
+ string(key: string): string | null;
55
+ }
56
+
57
+ const name = (kind: Kind, key: string) => `${kind} ${key}`;
58
+
59
+ export class StatusCache {
60
+ private readonly redis: RedisClient;
61
+ private readonly maxAgeMs: number;
62
+ private readonly now: () => number;
63
+ private readonly entries = new Map<string, Entry>();
64
+ private readonly reading = new Map<string, Promise<Map<string, Entry>>>();
65
+ /** Write-throughs so far, and the count at each entry's latest (for a read that a write overtook). */
66
+ private writes = 0;
67
+ private readonly written = new Map<string, number>();
68
+
69
+ constructor(options: { redis: RedisClient; maxAgeMs: number; now: () => number }) {
70
+ this.redis = options.redis;
71
+ this.maxAgeMs = options.maxAgeMs;
72
+ this.now = options.now;
73
+ }
74
+
75
+ /** What a poll reads, from memory where it's fresh enough, else from Redis (one batch, shared with any poll waiting on the same). */
76
+ async read(reads: StatusReads): Promise<StatusSnapshot> {
77
+ const wanted: [Kind, string][] = [
78
+ ...(reads.scans ?? []).map((k) => ['scan', k] as [Kind, string]),
79
+ ...(reads.hashes ?? []).map((k) => ['hash', k] as [Kind, string]),
80
+ ...(reads.sets ?? []).map((k) => ['set', k] as [Kind, string]),
81
+ ...(reads.strings ?? []).map((k) => ['string', k] as [Kind, string]),
82
+ ];
83
+ const now = this.now();
84
+ const waits = new Set<Promise<Map<string, Entry>>>();
85
+ const missing: [Kind, string][] = [];
86
+ for (const [kind, key] of wanted) {
87
+ const n = name(kind, key);
88
+ const inFlight = this.reading.get(n);
89
+ if (inFlight) waits.add(inFlight);
90
+ else {
91
+ const e = this.entries.get(n);
92
+ if (!e || now - e.at >= this.maxAgeMs) missing.push([kind, key]);
93
+ }
94
+ }
95
+ if (missing.length) {
96
+ const read = this.refresh(missing);
97
+ for (const [kind, key] of missing) this.reading.set(name(kind, key), read);
98
+ const done = () => {
99
+ for (const [kind, key] of missing) if (this.reading.get(name(kind, key)) === read) this.reading.delete(name(kind, key));
100
+ if (this.reading.size === 0) this.written.clear();
101
+ };
102
+ read.then(done, done);
103
+ waits.add(read);
104
+ }
105
+ const fetched = new Map<string, Entry>();
106
+ for (const got of await Promise.all(waits)) for (const [n, e] of got) fetched.set(n, e);
107
+ // The cache's own entry where it still has one (later write-throughs land in it), else what the read brought back,
108
+ // pinned now: a drop while the poll is still in its writes leaves it the records it read, never none.
109
+ const pinned = new Map<string, Entry | undefined>(wanted.map(([kind, key]) => [name(kind, key), this.entries.get(name(kind, key)) ?? fetched.get(name(kind, key))]));
110
+ const pick = (kind: Kind, key: string) => pinned.get(name(kind, key));
111
+ return {
112
+ scan: (prefix) => (pick('scan', prefix)?.value as Map<string, string> | undefined) ?? new Map(),
113
+ hash: (key) => (pick('hash', key)?.value as Map<string, string> | undefined) ?? new Map(),
114
+ set: (key) => (pick('set', key)?.value as Set<string> | undefined) ?? new Set(),
115
+ string: (key) => (pick('string', key)?.value as string | null | undefined) ?? null,
116
+ };
117
+ }
118
+
119
+ /** Read these entries from Redis: one batch, then one more for the scans' MGETs. */
120
+ private async refresh(entries: [Kind, string][]): Promise<Map<string, Entry>> {
121
+ const started = this.writes;
122
+ const at = this.now();
123
+ const commands = entries.map(([kind, key]): BatchCommand => {
124
+ if (kind === 'scan') return ['scan', `${key}*`];
125
+ if (kind === 'hash') return ['hgetall', key];
126
+ if (kind === 'set') return ['smembers', key];
127
+ return ['get', key];
128
+ });
129
+ const replies = (await this.redis.batch(commands)) as unknown[];
130
+ const scanned = entries.flatMap(([kind], i) => (kind === 'scan' && (replies[i] as string[]).length ? [i] : []));
131
+ const values = scanned.length ? ((await this.redis.batch(scanned.map((i): BatchCommand => ['mget', replies[i] as string[]]))) as (string | null)[][]) : [];
132
+ const out = new Map<string, Entry>();
133
+ entries.forEach(([kind, key], i) => {
134
+ const n = name(kind, key);
135
+ // Written since this read began: kept for now, but already old.
136
+ const age = (this.written.get(n) ?? 0) > started ? -Infinity : at;
137
+ let entry: Entry;
138
+ if (kind === 'scan') {
139
+ const keys = replies[i] as string[];
140
+ const got = values[scanned.indexOf(i)] ?? [];
141
+ const map = new Map<string, string>();
142
+ keys.forEach((k, j) => {
143
+ const v = got[j];
144
+ if (v !== null && v !== undefined) map.set(k, v);
145
+ });
146
+ entry = { kind, value: map, at: age };
147
+ } else if (kind === 'hash') entry = { kind, value: new Map(Object.entries(replies[i] as Record<string, string>)), at: age };
148
+ else if (kind === 'set') entry = { kind, value: new Set(replies[i] as string[]), at: age };
149
+ else entry = { kind, value: replies[i] as string | null, at: age };
150
+ this.entries.set(n, entry);
151
+ out.set(n, entry);
152
+ });
153
+ return out;
154
+ }
155
+
156
+ // ── Write-throughs (the modules call these once a write has landed) ─────────────
157
+
158
+ /** Note a write to these entry names; return the ones held. */
159
+ private touch(names: string[]): Entry[] {
160
+ this.writes++;
161
+ const held: Entry[] = [];
162
+ for (const n of names) {
163
+ // Only a read in flight needs to know (`refresh`); with none, nothing is remembered.
164
+ if (this.reading.size) this.written.set(n, this.writes);
165
+ const e = this.entries.get(n);
166
+ if (e) held.push(e);
167
+ }
168
+ return held;
169
+ }
170
+
171
+ /** The scan entries (held or not) whose prefix this key is under. */
172
+ private scansOf(key: string): string[] {
173
+ const out: string[] = [];
174
+ for (const n of [...this.entries.keys(), ...this.reading.keys()]) if (n.startsWith('scan ') && key.startsWith(n.slice(5))) out.push(n);
175
+ return [...new Set(out)];
176
+ }
177
+
178
+ /** A string key now holds `value` (null: deleted). */
179
+ string(key: string, value: string | null): void {
180
+ for (const e of this.touch([name('string', key), ...this.scansOf(key)])) {
181
+ if (e.kind === 'string') e.value = value;
182
+ else if (e.kind === 'scan') {
183
+ if (value === null) e.value.delete(key);
184
+ else e.value.set(key, value);
185
+ }
186
+ }
187
+ }
188
+
189
+ /** A hash's fields were set, or deleted. */
190
+ hash(key: string, set: Record<string, string>, del: readonly string[] = []): void {
191
+ for (const e of this.touch([name('hash', key)])) {
192
+ if (e.kind !== 'hash') continue;
193
+ for (const [f, v] of Object.entries(set)) e.value.set(f, v);
194
+ for (const f of del) e.value.delete(f);
195
+ }
196
+ }
197
+
198
+ /** Members were added to a set. */
199
+ add(key: string, ...members: string[]): void {
200
+ for (const e of this.touch([name('set', key)])) if (e.kind === 'set') for (const m of members) e.value.add(m);
201
+ }
202
+
203
+ /** The key changed in a way not known here (a compare-and-swap that lost): forget it, and any scan it's under. */
204
+ drop(key: string): void {
205
+ const names = [...(['string', 'hash', 'set'] as const).map((k) => name(k, key)), ...this.scansOf(key)];
206
+ this.touch(names);
207
+ for (const n of names) this.entries.delete(n);
208
+ }
209
+ }
210
+
211
+ /** Run a write to `key`; one that fails (lost in flight, so it may or may not have landed) drops the key from the cache. */
212
+ export async function landed<T>(cache: StatusCache | null, key: string, write: () => Promise<T>): Promise<T> {
213
+ try {
214
+ return await write();
215
+ } catch (error) {
216
+ cache?.drop(key);
217
+ throw error;
218
+ }
219
+ }
@@ -62,7 +62,11 @@ export interface PublishRecord {
62
62
  tryAgainAt: string | null;
63
63
  }
64
64
 
65
- export type PublishState = 'publishing' | 'live' | 'failed';
65
+ /**
66
+ * `slow` (runs-anywhere Decision 10): still not live well after it was made, or after its
67
+ * last Try again, with no failure known: "taking longer than usual. It may have failed."
68
+ */
69
+ export type PublishState = 'publishing' | 'live' | 'failed' | 'slow';
66
70
 
67
71
  /** One GitHub Deployment and its newest status (null: no status yet). */
68
72
  export interface DeploymentFact {
@@ -76,7 +80,7 @@ const FINAL = new Set(['success', 'failure', 'error', 'inactive']);
76
80
 
77
81
  export interface PublishFacts {
78
82
  record: PublishRecord;
79
- /** The running server's commit (RAILWAY_GIT_COMMIT_SHA / CARACMS_RUNNING_COMMIT), or null with no deploy pipeline. */
83
+ /** The running server's commit (mode.ts → `runningCommitFrom`), or null with none known. */
80
84
  running: string | null;
81
85
  /** Is `running` the publish commit or a descendant of it? null when not known (not asked). */
82
86
  runningIncludesPublish: boolean | null;
@@ -84,6 +88,9 @@ export interface PublishFacts {
84
88
  laterOnMain: ReadonlySet<string>;
85
89
  /** The repo's deployments, any order. */
86
90
  deployments: DeploymentFact[];
91
+ /** Now (ms), and how long a publish may take before it reads as `slow` (`LiveTimings.publishSlowMs`). Either unset: never slow. */
92
+ now?: number;
93
+ slowAfterMs?: number;
87
94
  }
88
95
 
89
96
  /**
@@ -94,6 +101,10 @@ export interface PublishFacts {
94
101
  * - **failed**: the newest deployment that has *answered*, for the publish commit or
95
102
  * a later commit on `main` (since the last Try again), says `failure` or `error`.
96
103
  * A deployment stuck `in_progress` is never read as finished or failed.
104
+ * - **slow** (runs-anywhere Decision 10): not live and not failed, `slowAfterMs` after the
105
+ * publish was made, or after its last Try again (`tryAgainAt`). Without deployments a
106
+ * failure can never be known, and on Railway a skipped deploy stays `in_progress` for
107
+ * good; a running commit that isn't in the repo never includes the publish either.
97
108
  * - **publishing**: otherwise.
98
109
  */
99
110
  export function publishState(f: PublishFacts): { state: PublishState; assumed: boolean } {
@@ -105,5 +116,6 @@ export function publishState(f: PublishFacts): { state: PublishState; assumed: b
105
116
  .sort((a, b) => b.createdAt.localeCompare(a.createdAt));
106
117
  const newest = answered[0];
107
118
  if (newest && (newest.state === 'failure' || newest.state === 'error')) return { state: 'failed', assumed: false };
119
+ if (f.now !== undefined && f.slowAfterMs !== undefined && f.now - Date.parse(f.record.tryAgainAt ?? f.record.at) >= f.slowAfterMs) return { state: 'slow', assumed: false };
108
120
  return { state: 'publishing', assumed: false };
109
121
  }
@@ -29,6 +29,22 @@ export interface LiveTimings {
29
29
  autosaveTtlMs: number;
30
30
  /** Decision 13: each tab polls the status this often (the editor's POLL_MS; /admin passes it to the page). */
31
31
  pollMs: number;
32
+ /** Runs-anywhere Decision 10: a publish not live this long after it was made (or its last Try again) reads as taking longer than usual. */
33
+ publishSlowMs: number;
34
+ /** Runs-anywhere (Watch-outs): Publish's request answers by this long, well inside Cloudflare's 100 s; the Publish carries on past it. */
35
+ publishAnswerMs: number;
36
+ /** Redis diet Decision 1: the status poll's cached reads are re-read from Redis once this old (counted from the last Redis read). */
37
+ statusCacheMs: number;
38
+ /** Redis diet Decision 6: a tab's heartbeat goes only once its lock's last beat is this old (or to take the lock back). */
39
+ beatEveryMs: number;
40
+ /** Redis diet Decision 8: a visible tab with no input this long polls at `slowPollMs` (/admin passes it to the page). */
41
+ idleSlowAfterMs: number;
42
+ /** Redis diet Decision 8: an idle tab's poll interval (/admin passes it to the page). */
43
+ slowPollMs: number;
44
+ /** Redis diet Decision 10: once the tab holds the document, a keep goes this long after the last change (/admin passes it to the page). */
45
+ keepDelayMs: number;
46
+ /** Redis diet Decision 10: …and never later than this after the first change not yet sent (/admin passes it to the page). */
47
+ keepCapMs: number;
32
48
  }
33
49
 
34
50
  export const DEFAULT_TIMINGS: LiveTimings = {
@@ -44,4 +60,12 @@ export const DEFAULT_TIMINGS: LiveTimings = {
44
60
  presenceMs: 15_000,
45
61
  autosaveTtlMs: 7 * 24 * 60 * 60_000,
46
62
  pollMs: 5000,
63
+ publishSlowMs: 15 * 60_000,
64
+ publishAnswerMs: 60_000,
65
+ statusCacheMs: 30_000,
66
+ beatEveryMs: 30_000,
67
+ idleSlowAfterMs: 2 * 60_000,
68
+ slowPollMs: 30_000,
69
+ keepDelayMs: 3000,
70
+ keepCapMs: 15_000,
47
71
  };
package/src/media.ts CHANGED
@@ -25,7 +25,7 @@
25
25
  */
26
26
  import { createHmac } from 'node:crypto';
27
27
  import type { ImageFormat } from './fields.ts';
28
- import type { RenderImage } from './media-value.ts';
28
+ import { renderMedia, type RenderImage } from './media-value.ts';
29
29
 
30
30
  /** The srcset width ladder every fluid image draws from (Decision 2). 1920 at the top: imgproxy doesn't upscale. */
31
31
  export const IMAGE_WIDTHS: readonly number[] = [160, 320, 480, 640, 960, 1280, 1920];
@@ -253,3 +253,28 @@ export function brandImageUrl(key: string, cssHeight: number, config: MediaConfi
253
253
  if (config.kind !== 'ready') return null;
254
254
  return imageUrl(config.settings, key, { height: cssHeight * 2 });
255
255
  }
256
+
257
+ /**
258
+ * Urban Return port, Decision 20: one URL for a Site Settings image, for the page's frame
259
+ * (a header, a footer, a layout), which has no block's `cms` for `<Media>`. Exported from
260
+ * `caracms/content`:
261
+ *
262
+ * const settings = await siteSettings(site, Astro.locals);
263
+ * const url = settingsImageUrl(settings.megaPhoto, { width: 640, height: 400 });
264
+ *
265
+ * `value` is the field as `siteSettings` gives it (resolved, M9), or the stored value. The
266
+ * rules are `<Media>`'s: the signed imgproxy URL, `fit` to one side, `fill` to both with the
267
+ * focal point's gravity, a GIF or an SVG passed through unresized, WebP; under
268
+ * `CARACMS_MEDIA=memory` the in-memory media's URL. No size: `fit` at the ladder's top rung.
269
+ * Null for an empty value (or a clip, or a file's key) and when the media isn't set up.
270
+ */
271
+ export function settingsImageUrl(value: unknown, options: { width?: number; height?: number } = {}): string | null {
272
+ const image = renderMedia(value, { only: 'image' });
273
+ if (!image || image.kind !== 'image') return null;
274
+ const config = readMediaConfig();
275
+ if (config.kind !== 'ready') return null;
276
+ const width = options.width ?? 0;
277
+ const height = options.height ?? 0;
278
+ const sized = width > 0 || height > 0 ? { width, height } : { width: IMAGE_WIDTHS[IMAGE_WIDTHS.length - 1]! };
279
+ return imageUrl(config.settings, image.key, { ...sized, focal: image.focal });
280
+ }