@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 +6 -0
- package/package.json +1 -1
- package/src/mailbox.ts +18 -4
- package/src/store.ts +17 -7
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
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
|
-
|
|
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
|
-
|
|
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),
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ||
|
|
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(() => {});
|