@chatpanel/events 0.50.0 → 0.53.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.
- package/package.json +2 -1
- package/schedule.js +45 -0
- package/sync-plan.js +43 -0
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/events",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.53.0",
|
|
4
4
|
"description": "The canonical ChatPanel event-log and capability contracts — typed durable facts, clock-free deterministic linearization, schema upcasting, and the invariants the replay harness asserts. Pure, dependency-free ESM shared by the ChatPanel extension, gateway and bridge.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"exports": {
|
|
8
8
|
".": "./index.js",
|
|
9
9
|
"./adapters.js": "./adapters.js",
|
|
10
|
+
"./attribution.js": "./attribution.js",
|
|
10
11
|
"./backup-envelope.js": "./backup-envelope.js",
|
|
11
12
|
"./capability.js": "./capability.js",
|
|
12
13
|
"./citations.js": "./citations.js",
|
package/schedule.js
CHANGED
|
@@ -112,6 +112,51 @@ export function nextFireAt(schedule, from) {
|
|
|
112
112
|
}
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
/** Sunday-first, matching `Date#getDay()` and the `weekday` field. */
|
|
116
|
+
export const WEEKDAY_NAMES = Object.freeze([
|
|
117
|
+
'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday',
|
|
118
|
+
]);
|
|
119
|
+
|
|
120
|
+
const hhmm = (s) => `${String(s?.hour ?? 0).padStart(2, '0')}:${String(s?.minute ?? 0).padStart(2, '0')}`;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* A schedule as a sentence: "every weekday at 08:00", not `{kind:'daily',hour:8,...}`.
|
|
124
|
+
*
|
|
125
|
+
* It lives beside the model rather than in a client because a schedule the user cannot read
|
|
126
|
+
* back is a schedule they cannot trust, and every client has to solve that. The desktop and
|
|
127
|
+
* the extension had already written this twice, and the copies disagreed: one rendered
|
|
128
|
+
* `weekdaysOnly` and the other silently dropped it, so a job that skipped weekends still
|
|
129
|
+
* read as "every day".
|
|
130
|
+
*
|
|
131
|
+
* Returns '' for a schedule it cannot describe rather than inventing one — an unreadable
|
|
132
|
+
* label is better than a confident wrong one.
|
|
133
|
+
*/
|
|
134
|
+
export function describeSchedule(s) {
|
|
135
|
+
if (!s || typeof s !== 'object') return '';
|
|
136
|
+
switch (s.kind) {
|
|
137
|
+
case 'once': {
|
|
138
|
+
if (!(s.at > 0)) return '';
|
|
139
|
+
return `once, at ${new Date(s.at).toLocaleString()}`;
|
|
140
|
+
}
|
|
141
|
+
case 'interval': {
|
|
142
|
+
const ms = Number(s.everyMs) || 0;
|
|
143
|
+
if (ms < 60_000) return '';
|
|
144
|
+
const mins = Math.round(ms / 60_000);
|
|
145
|
+
if (mins % 1440 === 0) { const d = mins / 1440; return `every ${d === 1 ? 'day' : `${d} days`}`; }
|
|
146
|
+
if (mins % 60 === 0) { const h = mins / 60; return `every ${h === 1 ? 'hour' : `${h} hours`}`; }
|
|
147
|
+
return `every ${mins} minutes`;
|
|
148
|
+
}
|
|
149
|
+
case 'daily':
|
|
150
|
+
return s.weekdaysOnly ? `every weekday at ${hhmm(s)}` : `every day at ${hhmm(s)}`;
|
|
151
|
+
case 'weekly': {
|
|
152
|
+
const name = WEEKDAY_NAMES[s.weekday];
|
|
153
|
+
return name ? `every ${name} at ${hhmm(s)}` : '';
|
|
154
|
+
}
|
|
155
|
+
default:
|
|
156
|
+
return '';
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
115
160
|
/** Every firing in (from, to], oldest first. Capped: a long sleep is not a queue of work. */
|
|
116
161
|
export function occurrencesBetween(schedule, from, to, max = MAX_CATCH_UP) {
|
|
117
162
|
const out = [];
|
package/sync-plan.js
CHANGED
|
@@ -64,6 +64,25 @@ function indexById(entries) {
|
|
|
64
64
|
* 'push' — local is newer, or remote has never seen it; send ours
|
|
65
65
|
* 'conflict' — both changed since the last common state and neither is clearly newer
|
|
66
66
|
*/
|
|
67
|
+
/**
|
|
68
|
+
* Is this entry the COMPLETE record, or a flattened stand-in for one?
|
|
69
|
+
*
|
|
70
|
+
* A warm/indexed copy of a chat is its transcript as one blob of text: enough to search,
|
|
71
|
+
* read and cite, and not enough to open as a conversation. Callers stamp that with
|
|
72
|
+
* `meta.lossy` (or `meta.origin: 'warm'`); a stamp row may carry `lossy` directly, because a
|
|
73
|
+
* sync plan works on stamps and should not have to load bodies to make this decision.
|
|
74
|
+
*
|
|
75
|
+
* Returns 1 for full fidelity and 0 for lossy — an ordering, so the rule below is a
|
|
76
|
+
* comparison rather than a pile of branches.
|
|
77
|
+
*/
|
|
78
|
+
export function fidelityOf(entry) {
|
|
79
|
+
if (!entry) return 0;
|
|
80
|
+
if (entry.lossy === true) return 0;
|
|
81
|
+
const meta = entry.meta || null;
|
|
82
|
+
if (meta && (meta.lossy === true || meta.origin === 'warm')) return 0;
|
|
83
|
+
return 1;
|
|
84
|
+
}
|
|
85
|
+
|
|
67
86
|
export function decide(local, remote, { tolerance = CLOCK_TOLERANCE_MS, base = null } = {}) {
|
|
68
87
|
if (!local && !remote) return 'none';
|
|
69
88
|
if (!remote) return 'push';
|
|
@@ -71,6 +90,30 @@ export function decide(local, remote, { tolerance = CLOCK_TOLERANCE_MS, base = n
|
|
|
71
90
|
|
|
72
91
|
const l = stampOf(local);
|
|
73
92
|
const r = stampOf(remote);
|
|
93
|
+
|
|
94
|
+
// FIDELITY BEATS RECENCY, BOTH WAYS.
|
|
95
|
+
//
|
|
96
|
+
// The two sides are not always describing the same kind of thing. One may hold the whole
|
|
97
|
+
// record and the other a flattened stand-in for it — and those two arrive with the SAME
|
|
98
|
+
// timestamp, because they describe the same moment. Last-write-wins then reads them as
|
|
99
|
+
// equal and answers 'none', which is how a complete conversation restored from a backup
|
|
100
|
+
// gets silently refused in favour of a search-index summary of itself that is already
|
|
101
|
+
// there. The user restores, is told it worked, and still sees the flattened copy.
|
|
102
|
+
//
|
|
103
|
+
// So a complete record always wins over a partial one whatever the clocks say. The
|
|
104
|
+
// converse — never let a partial overwrite a complete one — is the same rule read the
|
|
105
|
+
// other way, and it is the one callers usually remember to enforce by hand.
|
|
106
|
+
//
|
|
107
|
+
// DELETIONS ARE EXEMPT. A tombstone carries no body, so it is 'lossy' by any measure, and
|
|
108
|
+
// resurrecting deleted records because their replacement looks fuller would be a far worse
|
|
109
|
+
// bug than the one this fixes. When either side is deleted, the stamps decide.
|
|
110
|
+
const deleted = num(local.deletedAt) > 0 || num(remote.deletedAt) > 0;
|
|
111
|
+
if (!deleted) {
|
|
112
|
+
const lf = fidelityOf(local);
|
|
113
|
+
const rf = fidelityOf(remote);
|
|
114
|
+
if (lf !== rf) return rf > lf ? 'pull' : 'push';
|
|
115
|
+
}
|
|
116
|
+
|
|
74
117
|
const delta = l - r;
|
|
75
118
|
if (Math.abs(delta) <= tolerance) return 'none';
|
|
76
119
|
|