@ccmsg/cli 0.5.0 → 0.5.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ccmsg/cli",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "The ccmsg daemon, CLI and agent plugins for one instance (= one config home)",
5
5
  "license": "MIT",
6
6
  "author": "kawaz",
@@ -20,7 +20,7 @@
20
20
  "test": "bun test"
21
21
  },
22
22
  "dependencies": {
23
- "@ccmsg/protocol": "1.13.0"
23
+ "@ccmsg/protocol": "1.14.0"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/bun": "^1.3.0",
@@ -69,37 +69,69 @@ export function itemsRead(
69
69
  }
70
70
  const keep = selection(args.types === undefined ? {} : { types: args.types }, deps.presets);
71
71
  const { items } = select(within(classify(located(text)), args), keep);
72
- const page = paged(items, args.limit);
72
+ const page = paged(items, args.limit, backwards(args));
73
73
  return {
74
74
  items: page.items,
75
75
  ...(page.next === undefined ? {} : { next: page.next }),
76
+ ...(page.prev === undefined ? {} : { prev: page.prev }),
76
77
  ...(keep.keeps(IDS) ? { ids: ledger(page.items) } : {}),
77
78
  };
78
79
  }
79
80
 
81
+ /** Whether the range's end is the part to answer with.
82
+ *
83
+ * A caller that named where to start is reading forward from there; one that
84
+ * named only where to stop is looking at the newest of what it asked for, and
85
+ * answering with the oldest of that range would hand it the far side of a
86
+ * transcript it is walking back through. With neither bound the range is the
87
+ * whole transcript, which is read from its beginning. */
88
+ function backwards(bounds: TranscriptItemsReadArgs): boolean {
89
+ const lower =
90
+ bounds.since_at !== undefined ||
91
+ bounds.since_uuid !== undefined ||
92
+ bounds.since_id !== undefined;
93
+ const upper =
94
+ bounds.until_at !== undefined ||
95
+ bounds.until_uuid !== undefined ||
96
+ bounds.until_id !== undefined;
97
+ return upper && !lower;
98
+ }
99
+
80
100
  /** As much of the range as one answer carries, and where the next one starts.
81
101
  *
82
102
  * Two bounds, because either alone leaves a case unanswered: a count cannot
83
103
  * keep a page of long briefs inside what a connection should carry, and bytes
84
104
  * alone would answer with a number of items that varied with what was said in
85
- * them. Whichever is reached first ends the page, and the first item left out
86
- * is named so the caller resumes exactly where this stopped. */
105
+ * them. Whichever is reached first ends the page.
106
+ *
107
+ * Which end of the range is kept is the caller's, and the item named back is
108
+ * the one it continues from: reading forward, the first item left out, to be
109
+ * given as `since_id`; reading back, the first item answered, to be given as
110
+ * `until_id`. Either way the answer is oldest first, because that is the order
111
+ * a transcript has. */
87
112
  function paged(
88
113
  items: readonly Item[],
89
114
  limit: number | undefined,
90
- ): { items: Item[]; next?: string } {
115
+ back: boolean,
116
+ ): { items: Item[]; next?: string; prev?: string } {
91
117
  const most = Math.min(limit ?? ITEMS_LIMIT, ITEMS_LIMIT);
92
118
  const kept: Item[] = [];
93
119
  let held = 0;
94
- for (const item of items) {
120
+ for (let at = 0; at < items.length; at += 1) {
121
+ const item = items[back ? items.length - 1 - at : at];
122
+ if (item === undefined) continue;
95
123
  // A first item larger than the whole budget is still answered: a page of
96
124
  // nothing would leave the caller resuming at the item it just failed to
97
125
  // get, forever.
98
126
  if (kept.length > 0 && (kept.length >= most || held >= READ_LIMIT)) {
99
- return { items: kept, next: item.id };
127
+ if (!back) return { items: kept, next: item.id };
128
+ kept.reverse();
129
+ const first = kept[0];
130
+ return first === undefined ? { items: kept } : { items: kept, prev: first.id };
100
131
  }
101
132
  kept.push(item);
102
133
  held += JSON.stringify(item).length;
103
134
  }
135
+ if (back) kept.reverse();
104
136
  return { items: kept };
105
137
  }
@@ -13,4 +13,4 @@ export {
13
13
  } from "./fold.ts";
14
14
  export { READ_LIMIT, readSlice } from "./read.ts";
15
15
  export { type Appended, FOLD_TAIL_BYTES, TranscriptTail } from "./tail.ts";
16
- export { Transcripts, type TranscriptsDeps } from "./transcripts.ts";
16
+ export { ITEMS_SNAPSHOT, Transcripts, type TranscriptsDeps } from "./transcripts.ts";
@@ -135,8 +135,12 @@ export class Classification {
135
135
  read(record: Row, source: { offset: number; bytes: number }): void {
136
136
  const type = str(record["type"]);
137
137
  if (type === undefined || NOT_ITEMS.has(type)) return;
138
- const uuid = str(record["uuid"]) ?? "";
139
- if (uuid === "") return;
138
+ // A record the harness wrote without an id of its own still happened, and
139
+ // an item is pointed at by the record it came from — so where the record
140
+ // stands in the file stands in for the id it lacks. The `@` says which of
141
+ // the two it is, since a reader that groups by record must not take an
142
+ // address for something the harness will write again.
143
+ const uuid = str(record["uuid"]) ?? `@${String(source.offset)}`;
140
144
  const at = instant(record["timestamp"]);
141
145
  // Where in its record an item stood. One record becomes the thinking, the
142
146
  // words and each call of a turn, and a link that named only the record
@@ -149,13 +149,16 @@ export function select(
149
149
  * A record bound cuts at that record's position rather than at its clock, so
150
150
  * records sharing an instant stay on their own side of the cut — which is the
151
151
  * whole reason there are two kinds. An item bound is finer than either: it
152
- * resumes inside a record whose earlier items were already answered for. */
152
+ * cuts inside a record whose other items were already answered for, closed at
153
+ * the lower end where it resumes a read and open at the upper end where it
154
+ * stops short of what the caller already holds. */
153
155
  export interface Bounds {
154
156
  readonly since_at?: number;
155
157
  readonly since_uuid?: string;
156
158
  readonly since_id?: string;
157
159
  readonly until_at?: number;
158
160
  readonly until_uuid?: string;
161
+ readonly until_id?: string;
159
162
  }
160
163
 
161
164
  /** The bounds as stated, refused where they say two things at once.
@@ -170,8 +173,11 @@ export function bounded(bounds: Bounds): void {
170
173
  if (lower > 1) {
171
174
  throw new OpError("invalid_args", "a lower bound is a time, a record or an item, not several");
172
175
  }
173
- if (bounds.until_at !== undefined && bounds.until_uuid !== undefined) {
174
- throw new OpError("invalid_args", "an upper bound is a time or a record, not both");
176
+ const upper = [bounds.until_at, bounds.until_uuid, bounds.until_id].filter(
177
+ (one) => one !== undefined,
178
+ ).length;
179
+ if (upper > 1) {
180
+ throw new OpError("invalid_args", "an upper bound is a time, a record or an item, not several");
175
181
  }
176
182
  }
177
183
 
@@ -200,6 +206,9 @@ export function within(items: readonly Item[], bounds: Bounds): Item[] {
200
206
  }
201
207
  if (bounds.since_at !== undefined && item.at < bounds.since_at) continue;
202
208
  if (bounds.until_at !== undefined && item.at > bounds.until_at) break;
209
+ // An upper bound by item is open: it names an item the caller already
210
+ // holds, so the range ends before it rather than at it.
211
+ if (bounds.until_id !== undefined && item.id === bounds.until_id) break;
203
212
  kept.push(item);
204
213
  // An upper bound by record is inclusive and cuts after the last item that
205
214
  // record became, so the rest of the same record is still let through.
@@ -1,4 +1,4 @@
1
- import { type FSWatcher, statSync, watch } from "node:fs";
1
+ import { closeSync, type FSWatcher, openSync, readSync, statSync, watch } from "node:fs";
2
2
  import { open, stat } from "node:fs/promises";
3
3
  import { CONFIRM_POLL_MS } from "../sessions/harness.ts";
4
4
 
@@ -86,11 +86,16 @@ export class TranscriptTail {
86
86
  }
87
87
 
88
88
  /** Begin following, seeding the fold from the end of what is already there.
89
- * Resolves once the seed has been read, so a snapshot taken after it states
90
- * a size the fold has caught up with. */
89
+ *
90
+ * The seed is read before this returns rather than awaited, for the reason
91
+ * the size is read in the constructor: a subscription's snapshot is answered
92
+ * in the same turn the tail is started, and what the seed settles — the
93
+ * fold's values, and the items a subscriber opens on — would otherwise be
94
+ * stated as empty and the whole existing end of the file would arrive later
95
+ * as though it had just been appended. */
91
96
  async start(): Promise<void> {
92
97
  if (this.running) return;
93
- await this.#seed();
98
+ this.#seed();
94
99
  try {
95
100
  this.#watcher = watch(this.path, () => void this.refresh());
96
101
  } catch {
@@ -116,11 +121,11 @@ export class TranscriptTail {
116
121
  return this.#reading;
117
122
  }
118
123
 
119
- async #seed(): Promise<void> {
124
+ #seed(): void {
120
125
  const size = this.#size;
121
126
  if (size === 0) return;
122
127
  const from = Math.max(0, size - FOLD_TAIL_BYTES);
123
- const complete = whole(await this.#slice(from, size));
128
+ const complete = whole(this.#sliceSync(from, size));
124
129
  this.#offset = from + complete.byteLength;
125
130
  // The first line is half a record whenever the read began mid-file, so it
126
131
  // is dropped: what is read are whole records or nothing.
@@ -164,6 +169,26 @@ export class TranscriptTail {
164
169
  * arithmetic puts it, inside a character as readily as before one, and
165
170
  * decoding first would turn those bytes into a replacement character of a
166
171
  * different length and move every offset derived from it. */
172
+ /** The same range, read without yielding, which is what the seed is read
173
+ * through: the turn that starts a tail is the turn that answers a
174
+ * subscription, and it has to hold the end of the file by then. Bounded by
175
+ * `FOLD_TAIL_BYTES` however large the transcript is. */
176
+ #sliceSync(from: number, to: number): Buffer {
177
+ if (to <= from) return Buffer.alloc(0);
178
+ let handle: number;
179
+ try {
180
+ handle = openSync(this.path, "r");
181
+ } catch {
182
+ return Buffer.alloc(0);
183
+ }
184
+ try {
185
+ const buffer = Buffer.alloc(to - from);
186
+ return buffer.subarray(0, readSync(handle, buffer, 0, buffer.length, from));
187
+ } finally {
188
+ closeSync(handle);
189
+ }
190
+ }
191
+
167
192
  async #slice(from: number, to: number): Promise<Buffer> {
168
193
  if (to <= from) return Buffer.alloc(0);
169
194
  const handle = await open(this.path, "r").catch(() => undefined);