@ccmsg/cli 0.13.0 → 0.14.1

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/src/kv/store.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
1
+ import { readdirSync, readFileSync } from "node:fs";
2
+ import { mkdir, rename, unlink, writeFile } from "node:fs/promises";
2
3
  import { join } from "node:path";
3
4
  import type {
4
5
  KvDeleteArgs,
@@ -28,15 +29,38 @@ export const KV_DIR = "kv";
28
29
  * One file per namespace, whole-file: a namespace holds a handful of small
29
30
  * values, and the whole of it is what both a snapshot and a reload state. The
30
31
  * write lands through a temporary and a rename, so a kill leaves either the
31
- * previous namespace or the new one. */
32
+ * previous namespace or the new one.
33
+ *
34
+ * The files are read here, as the store is built — before this instance is
35
+ * accepting anything, so nobody is waiting on the read (DR-0015). Reading them
36
+ * when a namespace was first asked about would put the read inside the turn
37
+ * that answers a `kv.read` or opens a `kv:<ns>` subscription, and a snapshot is
38
+ * answered from what is held rather than from a promise. A namespace with no
39
+ * file starts empty, which is the same thing a namespace written for the first
40
+ * time after this starts from. */
32
41
  export class KvStore implements UpstreamResource {
33
42
  readonly #namespaces = new Map<string, Map<string, Held>>();
34
43
 
44
+ /** Per namespace, the writes already asked for, as one chain. */
45
+ readonly #writing = new Map<string, Promise<void>>();
46
+
35
47
  constructor(
36
48
  private readonly dir: string,
37
49
  private readonly self: InstanceId,
38
50
  private readonly publish: (topic: string, data: unknown) => void,
39
- ) {}
51
+ ) {
52
+ let names: string[];
53
+ try {
54
+ names = readdirSync(this.dir);
55
+ } catch {
56
+ // No directory yet, which is an instance nobody has written a value to.
57
+ return;
58
+ }
59
+ for (const name of names) {
60
+ if (!name.endsWith(".json")) continue;
61
+ this.#namespaces.set(name.slice(0, -".json".length), read(join(this.dir, name)));
62
+ }
63
+ }
40
64
 
41
65
  read(args: KvReadArgs): KvReadResult {
42
66
  const held = this.#load(args.ns).get(args.key);
@@ -54,7 +78,7 @@ export class KvStore implements UpstreamResource {
54
78
  * was unreachable must not displace what was written since. The answer is
55
79
  * what the key carries now — equal to what the caller stated when its write
56
80
  * stands, and later than it when an existing value did. */
57
- write(args: KvWriteArgs, now: Timestamp = Date.now()): KvWriteResult {
81
+ async write(args: KvWriteArgs, now: Timestamp = Date.now()): Promise<KvWriteResult> {
58
82
  const entries = this.#load(args.ns);
59
83
  const updatedAt = args.updated_at ?? now;
60
84
  const held = entries.get(args.key);
@@ -65,7 +89,9 @@ export class KvStore implements UpstreamResource {
65
89
  return { updated_at: held.updated_at };
66
90
  }
67
91
  entries.set(args.key, { value: args.value, updated_at: updatedAt });
68
- this.#persist(args.ns, entries);
92
+ // Told to the subscribers once it is written down, so nobody is holding a
93
+ // value this instance would not have after a restart.
94
+ await this.#persist(args.ns, entries);
69
95
  this.publish(`kv:${args.ns}`, {
70
96
  entries: [{ key: args.key, value: args.value, updated_at: updatedAt }],
71
97
  });
@@ -75,14 +101,14 @@ export class KvStore implements UpstreamResource {
75
101
  /** A key that was not there is no error: the caller wanted the namespace
76
102
  * without it, and it is. The removal is still announced, because a subscriber
77
103
  * that has the entry has to be told it is gone. */
78
- delete(args: KvDeleteArgs, now: Timestamp = Date.now()): KvDeleteResult {
104
+ async delete(args: KvDeleteArgs, now: Timestamp = Date.now()): Promise<KvDeleteResult> {
79
105
  const entries = this.#load(args.ns);
80
106
  const before = entries.get(args.key);
81
107
  // A removal older than what the key holds undoes nothing, which is the
82
108
  // same rule a write is held to.
83
109
  if (before !== undefined && before.updated_at > now) return {};
84
110
  entries.set(args.key, { updated_at: now, deleted: true });
85
- this.#persist(args.ns, entries);
111
+ await this.#persist(args.ns, entries);
86
112
  // A removal is announced only when something was there to remove: a
87
113
  // subscriber holding no entry has nothing to be told is gone.
88
114
  if (before !== undefined && before.deleted !== true) {
@@ -94,8 +120,7 @@ export class KvStore implements UpstreamResource {
94
120
  }
95
121
 
96
122
  /** Nothing to start or stop: the values are here whether anyone is watching
97
- * or not, and the file they live in is read the first time the namespace is
98
- * touched. */
123
+ * or not, and the files they live in were read as this was built. */
99
124
  start(): void {}
100
125
  stop(): void {}
101
126
 
@@ -110,57 +135,51 @@ export class KvStore implements UpstreamResource {
110
135
  return [{ instance: this.self, data: { entries } }];
111
136
  }
112
137
 
138
+ /** Settle once every write asked for so far has landed. What a stop waits on
139
+ * before it lets go of the config home (DESIGN §8.5 step 4). */
140
+ async flush(): Promise<void> {
141
+ await Promise.allSettled(this.#writing.values());
142
+ }
143
+
144
+ /** What the namespace holds, dropping the removals nothing can still be
145
+ * carrying an older write for. A name this store read no file for is a
146
+ * namespace with nothing in it. */
113
147
  #load(ns: string, now: Timestamp = Date.now()): Map<string, Held> {
114
- const known = this.#namespaces.get(ns);
115
- if (known !== undefined) return forget(known, now);
116
- const entries = new Map<string, Held>();
117
- let text: string;
118
- try {
119
- text = readFileSync(this.#file(ns), "utf8");
120
- } catch {
121
- this.#namespaces.set(ns, entries);
122
- return entries;
123
- }
124
- let parsed: unknown;
125
- try {
126
- parsed = JSON.parse(text);
127
- } catch {
128
- // A file a kill damaged states nothing this instance can act on, and
129
- // refusing every read of the namespace would be worse than starting it
130
- // empty: the next write replaces the file.
131
- parsed = undefined;
132
- }
133
- if (typeof parsed === "object" && parsed !== null) {
134
- for (const [key, held] of Object.entries(parsed as Record<string, unknown>)) {
135
- if (typeof held !== "object" || held === null) continue;
136
- const fields = held as Record<string, unknown>;
137
- const updatedAt = fields["updated_at"];
138
- if (typeof updatedAt !== "number") continue;
139
- entries.set(
140
- key,
141
- fields["deleted"] === true
142
- ? { updated_at: updatedAt, deleted: true }
143
- : { value: fields["value"], updated_at: updatedAt },
144
- );
145
- }
146
- }
147
- this.#namespaces.set(ns, entries);
148
- return forget(entries, now);
148
+ const known = this.#namespaces.get(ns) ?? new Map<string, Held>();
149
+ this.#namespaces.set(ns, known);
150
+ return forget(known, now);
149
151
  }
150
152
 
151
- #persist(ns: string, entries: Map<string, Held>): void {
152
- mkdirSync(this.dir, { recursive: true });
153
+ /** The namespace as it stands, written whole.
154
+ *
155
+ * The body is taken here, before anything is awaited, so what is written is
156
+ * the namespace as it was when the write was answered. Writes to one
157
+ * namespace are chained rather than started side by side: two of them would
158
+ * otherwise be racing for one file, and the older could land last (DR-0015).
159
+ * Namespaces do not wait on each other, having nothing in common but this
160
+ * directory. */
161
+ #persist(ns: string, entries: Map<string, Held>): Promise<void> {
153
162
  const file = this.#file(ns);
154
163
  const body: Record<string, Held> = {};
155
164
  for (const [key, held] of entries) body[key] = held;
156
- const temporary = `${file}.ccmsg-${process.pid}-${Date.now()}`;
157
- writeFileSync(temporary, JSON.stringify(body));
158
- try {
159
- renameSync(temporary, file);
160
- } catch (cause) {
161
- unlinkSync(temporary);
162
- throw cause;
163
- }
165
+ const written = (this.#writing.get(ns) ?? Promise.resolve()).then(async () => {
166
+ await mkdir(this.dir, { recursive: true });
167
+ const temporary = `${file}.ccmsg-${process.pid}-${Date.now()}`;
168
+ await writeFile(temporary, JSON.stringify(body));
169
+ try {
170
+ await rename(temporary, file);
171
+ } catch (cause) {
172
+ await unlink(temporary);
173
+ throw cause;
174
+ }
175
+ });
176
+ // The chain carries the order, not the outcome: a write that failed is
177
+ // answered to its own caller, and the ones behind it still go.
178
+ this.#writing.set(
179
+ ns,
180
+ written.catch(() => {}),
181
+ );
182
+ return written;
164
183
  }
165
184
 
166
185
  /** The namespace's file. A namespace is an identifier, so its name is a file
@@ -170,6 +189,35 @@ export class KvStore implements UpstreamResource {
170
189
  }
171
190
  }
172
191
 
192
+ /** One namespace's file, as the entries it states.
193
+ *
194
+ * A file a kill damaged states nothing this instance can act on, and refusing
195
+ * every read of the namespace would be worse than starting it empty: the next
196
+ * write replaces the file. */
197
+ function read(file: string): Map<string, Held> {
198
+ const entries = new Map<string, Held>();
199
+ let parsed: unknown;
200
+ try {
201
+ parsed = JSON.parse(readFileSync(file, "utf8"));
202
+ } catch {
203
+ return entries;
204
+ }
205
+ if (typeof parsed !== "object" || parsed === null) return entries;
206
+ for (const [key, held] of Object.entries(parsed as Record<string, unknown>)) {
207
+ if (typeof held !== "object" || held === null) continue;
208
+ const fields = held as Record<string, unknown>;
209
+ const updatedAt = fields["updated_at"];
210
+ if (typeof updatedAt !== "number") continue;
211
+ entries.set(
212
+ key,
213
+ fields["deleted"] === true
214
+ ? { updated_at: updatedAt, deleted: true }
215
+ : { value: fields["value"], updated_at: updatedAt },
216
+ );
217
+ }
218
+ return entries;
219
+ }
220
+
173
221
  /** Drop the removals nothing can still be carrying an older write for.
174
222
  *
175
223
  * Done where the namespace is read rather than on a clock of its own: a timer
@@ -188,9 +236,9 @@ export function kvHandlers(store: KvStore) {
188
236
  return {
189
237
  "kv.read": (input: HandlerInput): KvReadResult =>
190
238
  store.read(input.args as unknown as KvReadArgs),
191
- "kv.write": (input: HandlerInput): KvWriteResult =>
239
+ "kv.write": (input: HandlerInput): Promise<KvWriteResult> =>
192
240
  store.write(input.args as unknown as KvWriteArgs),
193
- "kv.delete": (input: HandlerInput): KvDeleteResult =>
241
+ "kv.delete": (input: HandlerInput): Promise<KvDeleteResult> =>
194
242
  store.delete(input.args as unknown as KvDeleteArgs),
195
243
  };
196
244
  }
@@ -67,17 +67,20 @@ export class Launcher {
67
67
  templates: this.config.templates.map((template) => ({
68
68
  name: template.name,
69
69
  command: template.command,
70
- params: template.params.map((param) => ({ name: param.name, default: param.default })),
70
+ params: template.params.map((param) => ({
71
+ name: param.name,
72
+ default: param.default,
73
+ })),
71
74
  })),
72
75
  };
73
76
  }
74
77
 
75
- tree(args: DirTreeArgs): DirTreeResult {
78
+ tree(args: DirTreeArgs): Promise<DirTreeResult> {
76
79
  return dirTree(this.config, args);
77
80
  }
78
81
 
79
- run(args: LauncherRunArgs): Promise<LauncherRunResult> {
80
- const cwd = insideRoots(this.config, args.cwd);
82
+ async run(args: LauncherRunArgs): Promise<LauncherRunResult> {
83
+ const cwd = await insideRoots(this.config, args.cwd);
81
84
  if (cwd === undefined) {
82
85
  // The op states no refusal for a path, so a directory outside the roots
83
86
  // is answered as what it is from here: an argument this launcher cannot
@@ -129,7 +132,7 @@ export function launcherHandlers(launcher: Launcher) {
129
132
  "launcher.config.read": (): LauncherConfigReadResult => launcher.configRead(),
130
133
  "launcher.run": (input: HandlerInput): Promise<LauncherRunResult> =>
131
134
  launcher.run(input.args as unknown as LauncherRunArgs),
132
- "dir.tree": (input: HandlerInput): DirTreeResult =>
135
+ "dir.tree": (input: HandlerInput): Promise<DirTreeResult> =>
133
136
  launcher.tree(input.args as unknown as DirTreeArgs),
134
137
  };
135
138
  }
@@ -1,4 +1,4 @@
1
- import { statSync } from "node:fs";
1
+ import { stat } from "node:fs/promises";
2
2
  import { isAbsolute } from "node:path";
3
3
  import type { LauncherConfig } from "../instance/config.ts";
4
4
  import { canonical, within } from "../files/index.ts";
@@ -13,19 +13,22 @@ import { canonical, within } from "../files/index.ts";
13
13
  *
14
14
  * A root that no longer resolves grants nothing and stops nothing: another
15
15
  * configured root may still hold the candidate. */
16
- export function insideRoots(config: LauncherConfig, path: string): string | undefined {
16
+ export async function insideRoots(
17
+ config: LauncherConfig,
18
+ path: string,
19
+ ): Promise<string | undefined> {
17
20
  if (!isAbsolute(path)) return undefined;
18
- const real = canonical(path);
19
- if (!isDirectory(real)) return undefined;
21
+ const real = await canonical(path);
22
+ if (!(await isDirectory(real))) return undefined;
20
23
  for (const root of config.root_dirs) {
21
- if (within(real, canonical(root))) return real;
24
+ if (within(real, await canonical(root))) return real;
22
25
  }
23
26
  return undefined;
24
27
  }
25
28
 
26
- export function isDirectory(path: string): boolean {
29
+ export async function isDirectory(path: string): Promise<boolean> {
27
30
  try {
28
- return statSync(path).isDirectory();
31
+ return (await stat(path)).isDirectory();
29
32
  } catch {
30
33
  return false;
31
34
  }
@@ -1,4 +1,4 @@
1
- import { readdirSync } from "node:fs";
1
+ import { readdir } from "node:fs/promises";
2
2
  import { join, relative } from "node:path";
3
3
  import type { DirTreeArgs, DirTreeEntry, DirTreeResult } from "@ccmsg/protocol";
4
4
  import type { LauncherConfig } from "../instance/config.ts";
@@ -18,29 +18,29 @@ const MAX_DEPTH = 5;
18
18
  * not hold contributes nothing rather than failing the request: the op states no
19
19
  * refusal for a path, and a tree assembled from several roots would otherwise be
20
20
  * lost whole because one of them went away. */
21
- export function dirTree(config: LauncherConfig, args: DirTreeArgs): DirTreeResult {
21
+ export async function dirTree(config: LauncherConfig, args: DirTreeArgs): Promise<DirTreeResult> {
22
22
  const depth = Math.min(MAX_DEPTH, args.depth ?? config.depth);
23
23
  // A filter of nothing is not a filter: an emptied search box shows the tree
24
24
  // rather than hiding all of it.
25
25
  const filter = args.filter === undefined || args.filter === "" ? undefined : args.filter;
26
26
  const entries: DirTreeEntry[] = [];
27
27
  for (const root of args.roots) {
28
- const real = insideRoots(config, root);
28
+ const real = await insideRoots(config, root);
29
29
  if (real === undefined) continue;
30
- entries.push(...walk(config, real, real, depth, filter));
30
+ entries.push(...(await walk(config, real, real, depth, filter)));
31
31
  }
32
32
  return { entries: sorted(entries) };
33
33
  }
34
34
 
35
- function walk(
35
+ async function walk(
36
36
  config: LauncherConfig,
37
37
  root: string,
38
38
  at: string,
39
39
  depth: number,
40
40
  filter: string | undefined,
41
- ): DirTreeEntry[] {
41
+ ): Promise<DirTreeEntry[]> {
42
42
  const entries: DirTreeEntry[] = [];
43
- for (const dirent of read(at)) {
43
+ for (const dirent of await read(at)) {
44
44
  // Design rationale: dot-directories are left out. This answers "where could
45
45
  // a session run", and a repository's `.git` is not one of those places —
46
46
  // browsing a session's own files is a different op with different rules.
@@ -49,24 +49,27 @@ function walk(
49
49
  if (dirent.isSymbolicLink()) {
50
50
  // A link is a place to run only if what it points at is one, so it goes
51
51
  // through the same containment its target would.
52
- if (insideRoots(config, path) === undefined) continue;
52
+ if ((await insideRoots(config, path)) === undefined) continue;
53
53
  } else if (!dirent.isDirectory()) continue;
54
54
 
55
- const children = depth > 1 ? walk(config, root, path, depth - 1, filter) : undefined;
55
+ const children = depth > 1 ? await walk(config, root, path, depth - 1, filter) : undefined;
56
56
  if (filter !== undefined) {
57
57
  const matches = relative(root, path).includes(filter);
58
58
  // An ancestor of a match survives the filter: without it a match several
59
59
  // levels down would have nothing to hang from.
60
60
  if (!matches && (children === undefined || children.length === 0)) continue;
61
61
  }
62
- entries.push({ path, ...(children === undefined ? {} : { children: sorted(children) }) });
62
+ entries.push({
63
+ path,
64
+ ...(children === undefined ? {} : { children: sorted(children) }),
65
+ });
63
66
  }
64
67
  return entries;
65
68
  }
66
69
 
67
- function read(dir: string) {
70
+ async function read(dir: string) {
68
71
  try {
69
- return readdirSync(dir, { withFileTypes: true });
72
+ return await readdir(dir, { withFileTypes: true });
70
73
  } catch {
71
74
  // A directory that cannot be read is still a place to run; what it holds is
72
75
  // simply not known, which is the same answer as holding nothing.
@@ -143,7 +143,7 @@ export class Delivery implements UpstreamResource {
143
143
  if (direct === "refused") {
144
144
  // Turned away for now, which is neither delivered nor undeliverable: it
145
145
  // waits in the inbox and is offered again (DESIGN §6.8).
146
- this.#hold(to, message);
146
+ await this.#hold(to, message);
147
147
  return { delivered: false, reason: "throttled" };
148
148
  }
149
149
 
@@ -160,11 +160,11 @@ export class Delivery implements UpstreamResource {
160
160
  // The session is listening but is behind on what it has already been
161
161
  // offered, which is the same standing as route (a) turning the message
162
162
  // away: it waits in the inbox and is offered again (DESIGN §6.8).
163
- this.#hold(to, message);
163
+ await this.#hold(to, message);
164
164
  return { delivered: false, reason: "throttled" };
165
165
  }
166
166
 
167
- const { evicted } = this.#hold(to, message);
167
+ const { evicted } = await this.#hold(to, message);
168
168
  return this.#undelivered(to, evicted ? "inbox_full" : this.#reason(state));
169
169
  };
170
170
 
@@ -173,9 +173,9 @@ export class Delivery implements UpstreamResource {
173
173
  * Stated before it is held, so that a message dropped to make room for it
174
174
  * reads in the order the two happened: a removal of something the watcher
175
175
  * has, rather than of something it is about to be told about. */
176
- #hold(to: Sid, message: InboxMessage): { evicted: boolean } {
176
+ async #hold(to: Sid, message: InboxMessage): Promise<{ evicted: boolean }> {
177
177
  this.#watchers(to, [message]);
178
- return this.deps.inbox.hold(to, message);
178
+ return await this.deps.inbox.hold(to, message);
179
179
  }
180
180
 
181
181
  /** What is waiting, as somebody looking at it from outside reads it.
@@ -247,11 +247,18 @@ export class Delivery implements UpstreamResource {
247
247
  * nothing waiting are not asked about, so the cost of a change nobody is owed
248
248
  * anything after is one map read. */
249
249
  retry = async (): Promise<void> => {
250
+ const offers: Promise<void>[] = [];
250
251
  for (const sid of this.deps.inbox.sids()) {
251
252
  const state = this.deps.sessions.classify(sid);
252
253
  if (state === undefined || state === "paused" || state === "disappeared") continue;
253
- await this.#offer(sid);
254
+ // One session at a time within its own offer, every session at once
255
+ // across them: a session that is slow to answer, or that never does
256
+ // before its deadline, is not a reason the next session waits (DR-0015).
257
+ // The order within a session is what `#offer` holds to, and it guards
258
+ // itself per sid.
259
+ offers.push(this.#offer(sid));
254
260
  }
261
+ await Promise.all(offers);
255
262
  };
256
263
 
257
264
  /** Hand a session what it is owed, oldest first, over route (a).
@@ -275,7 +282,7 @@ export class Delivery implements UpstreamResource {
275
282
  // delivered, and a daemon killed here must not offer those again.
276
283
  if (outcome !== "delivered") break;
277
284
  this.#claimed.get(to)?.delete(message.mid);
278
- this.deps.inbox.delivered(to, [message.mid]);
285
+ await this.deps.inbox.delivered(to, [message.mid]);
279
286
  }
280
287
  } finally {
281
288
  this.#claimed.delete(to);
@@ -324,10 +331,20 @@ export class Delivery implements UpstreamResource {
324
331
  const held = this.deps.inbox
325
332
  .undelivered(sid)
326
333
  .filter((message) => claimed?.has(message.mid) !== true);
327
- this.deps.inbox.delivered(
328
- sid,
329
- held.map((message) => message.mid),
330
- );
334
+ // The messages leave the inbox here, in memory, and the lines saying so go
335
+ // behind whatever the file already owes (DR-0015): the frame carrying them
336
+ // is queued on the connection before this returns, so what the session has
337
+ // been handed is settled whether or not the line has landed yet.
338
+ void this.deps.inbox
339
+ .delivered(
340
+ sid,
341
+ held.map((message) => message.mid),
342
+ )
343
+ .catch(() => {
344
+ // A line that could not be written costs a message being offered again
345
+ // on the next run, which is what the inbox does about anything it is
346
+ // unsure of.
347
+ });
331
348
  return [{ instance: this.deps.self, data: held }];
332
349
  }
333
350