@prjct.app/pi-team 0.5.4 → 0.5.5

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/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## [0.5.5](https://github.com/prjct-app/pi-team/compare/v0.5.4...v0.5.5) (2026-09-10)
2
+
3
+ ### Performance Improvements
4
+
5
+ * carry the canonical payload serialization instead of recomputing it ([#24](https://github.com/prjct-app/pi-team/issues/24)) ([340c495](https://github.com/prjct-app/pi-team/commit/340c4954358e715c6aed5e37e9f0a78f33100877))
6
+
1
7
  ## [0.5.4](https://github.com/prjct-app/pi-team/compare/v0.5.3...v0.5.4) (2026-09-10)
2
8
 
3
9
  ### Performance Improvements
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prjct.app/pi-team",
3
- "version": "0.5.4",
3
+ "version": "0.5.5",
4
4
  "description": "Coordinate independent PI Agent sessions with local team messaging, queued tasks, and shared results.",
5
5
  "type": "module",
6
6
  "keywords": [
package/src/mailbox.ts CHANGED
@@ -150,15 +150,22 @@ export class Mailbox {
150
150
  for (const attempt of ATTEMPTS) {
151
151
  const record = await this.readState(team);
152
152
  const state = record.payload;
153
- const before = JSON.stringify(state);
153
+ // The parser already produced this for the content hash. A pre-envelope
154
+ // record carries none, so that path serializes as before. A mismatch
155
+ // could only ever cause one redundant publication, never a lost write:
156
+ // `before` comes from the pre-action object and `after` from the
157
+ // post-action one, so they cannot coincide by accident.
158
+ const before = record.payloadJson ?? JSON.stringify(state);
154
159
  const presence = await this.readPresence(team);
155
160
  const swept = state.members.filter(member => member.status !== 'offline' && !this.alive(member, presence));
156
161
  for (const member of swept) this.disconnect(state, member);
157
162
  const result = action(state);
158
- if (before === JSON.stringify(state)) return result;
163
+ const after = JSON.stringify(state);
164
+ if (before === after) return result;
159
165
  if (!Value.Check(StateSchema, state)) throw new Error('Invalid mailbox format; refusing to write');
160
166
  try {
161
- await publish(this.recordPath(team), record.revision, state, this.normalize(team), { maxBytes: MAX_BYTES });
167
+ await publish(this.recordPath(team), record.revision, state, this.normalize(team),
168
+ { maxBytes: MAX_BYTES, payloadJson: after });
162
169
  await Promise.all(swept.map(member => unlink(this.presencePath(team, member.alias)).catch(() => {})));
163
170
  return result;
164
171
  } catch (error) {
@@ -223,7 +230,14 @@ export class Mailbox {
223
230
  await this.writePresence(member, status);
224
231
  }
225
232
 
226
- /** Lock-free consistent view of the record with presence-based statuses. */
233
+ /**
234
+ * Lock-free consistent view of the record with presence-based statuses.
235
+ *
236
+ * The cached read returns a shared record, and the message objects below are
237
+ * the record's own, not copies. Never mutate them: it would corrupt both the
238
+ * process-wide cache and the `payloadJson` taken alongside it. `mutate` is
239
+ * safe because it reads uncached.
240
+ */
227
241
  async snapshot(member: Membership): Promise<Snapshot> {
228
242
  const record = await this.readState(member.team, true);
229
243
  this.owner(record.payload, member);
package/src/store.ts CHANGED
@@ -14,7 +14,13 @@ import { dirname, join } from 'node:path';
14
14
  * hard-links its envelope into a bounded revisions/ history, which doubles as
15
15
  * recovery evidence for interrupted writes.
16
16
  */
17
- export type Record<T> = { revision: number; payload: T };
17
+ /**
18
+ * `payloadJson` is the canonical `JSON.stringify(payload)`, carried when the
19
+ * parser already had to compute it for the content hash. It is consistent by
20
+ * construction, never derived from the raw file text, and always optional: a
21
+ * pre-envelope record has none and callers fall back to serializing.
22
+ */
23
+ export type Record<T> = { revision: number; payload: T; payloadJson?: string };
18
24
  /** Parse raw file bytes into a record, throwing on corruption. Never deletes. */
19
25
  export type Normalize<T> = (raw: string) => Record<T>;
20
26
  /**
@@ -46,10 +52,11 @@ export function envelope<T>(raw: string): Record<T> {
46
52
  if (typeof parsed.revision !== 'number' || !Number.isSafeInteger(parsed.revision) || parsed.revision < 1) {
47
53
  throw Object.assign(new Error('Invalid record revision.'), { code: 'CORRUPT_RECORD' });
48
54
  }
49
- if (parsed.contentHash !== sha256(JSON.stringify(parsed.payload))) {
55
+ const payloadJson = JSON.stringify(parsed.payload);
56
+ if (parsed.contentHash !== sha256(payloadJson)) {
50
57
  throw Object.assign(new Error('Record hash mismatch; preserved for manual recovery.'), { code: 'CORRUPT_RECORD' });
51
58
  }
52
- return { revision: parsed.revision, payload: parsed.payload as T };
59
+ return { revision: parsed.revision, payload: parsed.payload as T, payloadJson };
53
60
  }
54
61
 
55
62
  function assertSafeFile(path: string, info: { isFile(): boolean; size: number; mode: number; uid: number }, maxBytes: number): void {
@@ -162,7 +169,7 @@ async function pruneRevisions(dir: string, latest: number): Promise<void> {
162
169
  */
163
170
  export async function publish<T>(
164
171
  path: string, expectedRevision: number, payload: T, normalize: Normalize<T>,
165
- options: { maxBytes: number; durability?: Durability },
172
+ options: { maxBytes: number; durability?: Durability; payloadJson?: string },
166
173
  ): Promise<Record<T>> {
167
174
  const durability = options.durability ?? 'full';
168
175
  await mkdir(dirname(path), { recursive: true, mode: 0o700 });
@@ -175,12 +182,15 @@ export async function publish<T>(
175
182
  throw Object.assign(new Error(`Record changed before the write; current revision is ${revision}.`), { code: 'STALE_REVISION' });
176
183
  }
177
184
  const next = revision + 1;
178
- const payloadJson = JSON.stringify(payload);
185
+ // Supplied by callers that already serialized this exact object; it must
186
+ // equal JSON.stringify(payload). The record stays self-consistent either
187
+ // way, because the hash is taken over the string that gets embedded.
188
+ const payloadJson = options.payloadJson ?? JSON.stringify(payload);
179
189
  const text = `{"schemaVersion":1,"revision":${next},"contentHash":"${sha256(payloadJson)}","payload":${payloadJson}}`;
180
190
  if (Buffer.byteLength(text) > options.maxBytes) throw new Error('Record size limit exceeded.');
181
191
  const historyPath = join(dirname(path), 'revisions', `${next}.json`);
182
192
  const previous = await readRecord(historyPath, (raw: string) => envelope<T>(raw), options.maxBytes);
183
- if (previous && (previous.revision !== next || sha256(JSON.stringify(previous.payload)) !== sha256(payloadJson))) {
193
+ if (previous && (previous.revision !== next || (previous.payloadJson ?? JSON.stringify(previous.payload)) !== payloadJson)) {
184
194
  throw new Error('An interrupted publication owns this revision; explicit recovery is required.');
185
195
  }
186
196
  if (!previous) await writeAtomic(historyPath, text, durability);
@@ -195,7 +205,7 @@ export async function publish<T>(
195
205
  cache.delete(path);
196
206
  counters.publishes++;
197
207
  await pruneRevisions(dirname(path), next).catch(() => {});
198
- return { revision: next, payload };
208
+ return { revision: next, payload, payloadJson };
199
209
  } finally {
200
210
  await lock.close();
201
211
  await unlink(lockPath).catch(() => {});