@ccmsg/cli 0.14.1 → 0.15.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.
@@ -1,17 +1,17 @@
1
1
  import type { InstanceId, Sid } from "@ccmsg/protocol";
2
2
  import { topicParam, type TopicValue, type UpstreamResource } from "../topics/index.ts";
3
+ import type { FoldCache } from "./cache.ts";
3
4
  import { NO_FACTS, type TranscriptFacts, TranscriptFold } from "./fold.ts";
4
5
  import { Classification, type Item, positioned } from "./items/index.ts";
5
6
  import { type Appended, TranscriptTail } from "./tail.ts";
6
7
 
7
8
  /** How many items a subscription to `transcript.items:<sid>` opens with.
8
9
  *
9
- * The tail of the same megabyte the fold is seeded from, bounded by a count
10
- * because that read is bounded by bytes: a file of many small records would
11
- * otherwise make the opening frame as large as the read that produced it. Two
12
- * hundred items is several turns at the sizes the harness writes, which is
13
- * more than a live view shows at once — and a client that wants further back
14
- * asks for it by range rather than waiting for a snapshot to grow. */
10
+ * The end of a reading that covers the whole file, bounded by a count because
11
+ * the whole file is what was read: an opening frame is what a live view draws,
12
+ * and two hundred items is several turns at the sizes the harness writes
13
+ * more than such a view shows at once. A client that wants further back asks
14
+ * for it by range rather than waiting for a snapshot to grow. */
15
15
  export const ITEMS_SNAPSHOT = 200;
16
16
 
17
17
  /** Which of the two topics a name is. Both are fed by one tail, so the
@@ -26,7 +26,11 @@ export interface TranscriptsDeps {
26
26
  * (DESIGN §4.2), or the `<sid>.jsonl` under this instance's `projects/` that
27
27
  * carries its name. A sid neither names nor is named by a file there has
28
28
  * none, and nothing is guessed for it. */
29
- readonly pathOf: (sid: Sid) => string | undefined;
29
+ readonly pathOf: (sid: Sid) => Promise<string | undefined>;
30
+ /** Where a reading of a transcript is kept so the next one resumes from it.
31
+ * Absent leaves every reading starting from the file's beginning, which is
32
+ * the same answer at the price of reading it again. */
33
+ readonly cache?: FoldCache;
30
34
  /** The one way a value reaches subscribers (DESIGN §6.1). */
31
35
  readonly publish: (topic: string, data: unknown) => void;
32
36
  /** The fold now says something different about this session. What the fold
@@ -70,16 +74,20 @@ export class Transcripts implements UpstreamResource {
70
74
  * there, which is the whole of the snapshot for a topic whose frames are an
71
75
  * append rather than a value (DESIGN §6.2). A session whose transcript this
72
76
  * instance cannot find has nothing to state, and the subscriber begins at
73
- * the first thing appended after one appears. */
74
- snapshot(topic: string): readonly TopicValue[] {
77
+ * the first thing appended after one appears.
78
+ *
79
+ * Answered once the transcript has been read, which is what makes the size
80
+ * and the items it states describe the same whole file the fold does. */
81
+ async snapshot(topic: string): Promise<readonly TopicValue[]> {
75
82
  const sid = topicParam(topic);
76
- const followed = sid === undefined ? undefined : this.#followed.get(sid);
77
- if (sid === undefined || followed === undefined) return [];
83
+ if (sid === undefined) return [];
84
+ await this.ready(sid);
85
+ const followed = this.#followed.get(sid);
86
+ const tail = followed?.tail;
87
+ if (followed === undefined || tail === undefined) return [];
78
88
  // The items topic holds a list that is only appended to, so its snapshot
79
89
  // is the end of that list rather than a place to start from.
80
- const data = isItems(topic)
81
- ? { sid, items: [...followed.recent] }
82
- : { sid, size: followed.tail.size };
90
+ const data = isItems(topic) ? { sid, items: [...followed.recent] } : { sid, size: tail.size };
83
91
  return [{ instance: this.deps.self, data }];
84
92
  }
85
93
 
@@ -89,10 +97,17 @@ export class Transcripts implements UpstreamResource {
89
97
  return this.#followed.get(sid)?.fold.facts ?? NO_FACTS;
90
98
  }
91
99
 
100
+ /** When the transcript held for a session has been read. Whoever states a
101
+ * value the fold settles waits on this, so what is stated describes the
102
+ * whole file rather than the part of it read so far (CT-Q8). */
103
+ ready(sid: Sid): Promise<void> {
104
+ return this.#followed.get(sid)?.ready ?? Promise.resolve();
105
+ }
106
+
92
107
  /** Whether a session's transcript is being followed, which is how "the
93
108
  * subscription drives the resource" is observable from outside. */
94
109
  following(sid: Sid): boolean {
95
- return this.#followed.get(sid)?.tail.running === true;
110
+ return this.#followed.get(sid)?.tail?.running === true;
96
111
  }
97
112
 
98
113
  /** Ask for a session's transcript to be followed. Each hold is released
@@ -103,11 +118,15 @@ export class Transcripts implements UpstreamResource {
103
118
  held.holds += 1;
104
119
  return;
105
120
  }
106
- const path = this.deps.pathOf(sid);
107
- if (path === undefined) return;
108
- const followed = this.#follow(sid, path);
121
+ const followed: Followed = {
122
+ holds: 1,
123
+ fold: new TranscriptFold(),
124
+ reading: new Classification(),
125
+ recent: [],
126
+ ready: Promise.resolve(),
127
+ };
109
128
  this.#followed.set(sid, followed);
110
- void followed.tail.start();
129
+ followed.ready = this.#open(sid, followed);
111
130
  }
112
131
 
113
132
  release(sid: Sid): void {
@@ -116,7 +135,8 @@ export class Transcripts implements UpstreamResource {
116
135
  held.holds -= 1;
117
136
  if (held.holds > 0) return;
118
137
  this.#followed.delete(sid);
119
- held.tail.stop();
138
+ held.tail?.stop();
139
+ void this.#remember(held);
120
140
  // What the fold held goes with it: the values it derived describe a file
121
141
  // this instance is no longer reading, and stating them from memory would
122
142
  // outlive the reading that justified them.
@@ -129,36 +149,100 @@ export class Transcripts implements UpstreamResource {
129
149
  const followed = new Map(this.#followed);
130
150
  this.#followed.clear();
131
151
  for (const [sid, entry] of followed) {
132
- entry.tail.stop();
152
+ entry.tail?.stop();
153
+ void this.#remember(entry);
133
154
  this.deps.onFacts(sid);
134
155
  }
135
156
  }
136
157
 
137
- #follow(sid: Sid, path: string): Followed {
138
- const fold = new TranscriptFold();
139
- const followed: Followed = {
140
- holds: 1,
141
- fold,
142
- reading: new Classification(),
143
- recent: [],
144
- tail: new TranscriptTail(path, {
145
- onSeed: (seeded) => {
146
- // The end of the file as it already stood: it settles what the fold
147
- // says and opens the reading that classifies what comes next, and it
148
- // is not an append, so nothing is published for it.
149
- this.#keep(sid, seeded);
150
- if (foldAll(fold, seeded.lines)) this.deps.onFacts(sid);
158
+ /** Find the file, take up whatever reading of it was left behind, and read
159
+ * the rest of it.
160
+ *
161
+ * Every await here is a window in which the last hold may be released, so
162
+ * what was true before each one is asked again after it (DR-0015 §2.5): a
163
+ * reading nobody wants any more stops where it is, rather than going on to
164
+ * put a watch on a file and states about it into a session that has since
165
+ * been opened afresh. */
166
+ async #open(sid: Sid, followed: Followed): Promise<void> {
167
+ try {
168
+ const path = await this.deps.pathOf(sid);
169
+ if (!this.#holds(sid, followed)) return;
170
+ if (path === undefined) {
171
+ // Nothing to follow. The entry goes rather than standing as a session
172
+ // with an empty fold, so a later hold looks for the file again.
173
+ this.#followed.delete(sid);
174
+ return;
175
+ }
176
+ followed.path = path;
177
+ const kept = await this.deps.cache?.read(path);
178
+ if (!this.#holds(sid, followed)) return;
179
+ let settled = false;
180
+ if (kept !== undefined) {
181
+ followed.fold.restore(kept.fold);
182
+ followed.recent.push(...kept.items);
183
+ // The reading resumes as the same reading: the turn it had reached and
184
+ // the calls it was still waiting on are taken up with the items they
185
+ // belong to, so a result answered now names the call a reader holds.
186
+ followed.reading.restore(kept.reading, followed.recent);
187
+ settled = true;
188
+ }
189
+ const tail = new TranscriptTail(path, {
190
+ onExisting: (read) => {
191
+ // What was already in the file: it settles what the fold says and
192
+ // opens the reading that classifies what comes next, and it is not an
193
+ // append, so nothing is published for it.
194
+ this.#keep(followed, read);
195
+ if (foldAll(followed.fold, read.lines)) settled = true;
151
196
  },
152
- onAppended: (appended) => this.#appended(sid, fold, appended),
197
+ onAppended: (appended) => this.#appended(sid, followed, appended),
153
198
  onTruncated: () => {
154
- fold.reset();
155
- this.#reset(sid);
199
+ followed.fold.reset();
200
+ this.#reset(followed);
201
+ void this.deps.cache?.drop(path);
156
202
  this.deps.onFacts(sid);
157
203
  },
158
204
  ...(this.deps.pollMs === undefined ? {} : { pollMs: this.deps.pollMs }),
159
- }),
160
- };
161
- return followed;
205
+ });
206
+ followed.tail = tail;
207
+ await tail.start(kept?.offset ?? 0);
208
+ if (!this.#holds(sid, followed)) {
209
+ // Released while the file was being read: the watch this just put on it
210
+ // is the only thing left of the reading, and it goes with it.
211
+ tail.stop();
212
+ return;
213
+ }
214
+ await this.#remember(followed);
215
+ // A file that said nothing says nothing: the reading is finished either
216
+ // way, and only a reading that settled something is news to the domain.
217
+ if (settled) this.deps.onFacts(sid);
218
+ } catch {
219
+ // The reading could not be made. The entry goes rather than standing as
220
+ // one whose `ready` will never settle, since every later hold would join
221
+ // the same failure instead of trying the file again.
222
+ if (this.#followed.get(sid) === followed) this.#followed.delete(sid);
223
+ followed.tail?.stop();
224
+ }
225
+ }
226
+
227
+ /** Whether this is still the reading that session is being followed by. A
228
+ * release during an await takes the entry out, and a hold after it puts a
229
+ * different one in; neither is this one. */
230
+ #holds(sid: Sid, followed: Followed): boolean {
231
+ return this.#followed.get(sid) === followed;
232
+ }
233
+
234
+ /** Write down where the reading has reached, so the next one starts there. */
235
+ async #remember(followed: Followed): Promise<void> {
236
+ const path = followed.path;
237
+ const tail = followed.tail;
238
+ if (path === undefined || tail === undefined) return;
239
+ await this.deps.cache?.save(
240
+ path,
241
+ tail.offset,
242
+ followed.fold.held,
243
+ followed.reading.held,
244
+ followed.recent,
245
+ );
162
246
  }
163
247
 
164
248
  /** The one pass over what was appended: the lines go to the fold, to the
@@ -170,8 +254,8 @@ export class Transcripts implements UpstreamResource {
170
254
  * now was made in bytes that went past long ago, and a reading started when
171
255
  * somebody subscribed would not know it. What the memory holds is bounded —
172
256
  * the calls still outstanding, and the items of the opening frame. */
173
- #appended(sid: Sid, fold: TranscriptFold, appended: Appended): void {
174
- const changed = foldAll(fold, appended.lines);
257
+ #appended(sid: Sid, followed: Followed, appended: Appended): void {
258
+ const changed = foldAll(followed.fold, appended.lines);
175
259
  this.deps.publish(`transcript:${sid}`, {
176
260
  sid,
177
261
  lines: [...appended.lines],
@@ -179,7 +263,7 @@ export class Transcripts implements UpstreamResource {
179
263
  end: appended.end,
180
264
  size: appended.size,
181
265
  });
182
- const items = this.#keep(sid, appended);
266
+ const items = this.#keep(followed, appended);
183
267
  // A record still being written was not read, so there is nothing to say
184
268
  // about it yet; a chunk whose records were all the interface's own
185
269
  // bookkeeping says nothing either.
@@ -191,9 +275,7 @@ export class Transcripts implements UpstreamResource {
191
275
  * subscription to open on. A result that fills in a call already handed over
192
276
  * is not sent again: the call named nothing to wait for and the result names
193
277
  * the call, so a reader ties the two together from what it already has. */
194
- #keep(sid: Sid, chunk: Appended): readonly Item[] {
195
- const followed = this.#followed.get(sid);
196
- if (followed === undefined) return [];
278
+ #keep(followed: Followed, chunk: Appended): readonly Item[] {
197
279
  const items = followed.reading.readAll(positioned(chunk.lines, chunk.start));
198
280
  followed.recent.push(...items);
199
281
  if (followed.recent.length > ITEMS_SNAPSHOT) {
@@ -204,9 +286,7 @@ export class Transcripts implements UpstreamResource {
204
286
 
205
287
  /** The file is not the one that was being read, so neither the reading nor
206
288
  * what it produced describes it. */
207
- #reset(sid: Sid): void {
208
- const followed = this.#followed.get(sid);
209
- if (followed === undefined) return;
289
+ #reset(followed: Followed): void {
210
290
  followed.reading = new Classification();
211
291
  followed.recent.length = 0;
212
292
  }
@@ -219,7 +299,10 @@ interface Followed {
219
299
  reading: Classification;
220
300
  /** The end of what has been read, which is what a subscription opens on. */
221
301
  readonly recent: Item[];
222
- readonly tail: TranscriptTail;
302
+ /** Settled once the file has been found and read. */
303
+ ready: Promise<void>;
304
+ path?: string;
305
+ tail?: TranscriptTail;
223
306
  }
224
307
 
225
308
  function foldAll(fold: TranscriptFold, lines: readonly string[]): boolean {