@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.
|
|
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.
|
|
23
|
+
"@ccmsg/protocol": "1.14.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@types/bun": "^1.3.0",
|
package/src/sessions/items.ts
CHANGED
|
@@ -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
|
|
86
|
-
*
|
|
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
|
-
|
|
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 (
|
|
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
|
}
|
package/src/transcript/index.ts
CHANGED
|
@@ -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
|
-
|
|
139
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
174
|
-
|
|
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.
|
package/src/transcript/tail.ts
CHANGED
|
@@ -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
|
-
*
|
|
90
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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);
|