@supersuit/suggest-a-change 0.1.2 → 0.1.4
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 +14 -0
- package/README.md +5 -5
- package/dist/client/detail.js +27 -4
- package/dist/client/mine.js +29 -4
- package/dist/client/reports.d.ts +2 -0
- package/dist/client/view-cache.d.ts +21 -6
- package/dist/client/view-cache.js +51 -0
- package/dist/contract.mjs +4 -3
- package/dist/server/handlers.d.ts +5 -0
- package/dist/server/handlers.js +68 -15
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.js +1 -1
- package/package.json +1 -1
- package/src/client/detail.tsx +14 -3
- package/src/client/mine.tsx +20 -4
- package/src/client/reports.ts +2 -0
- package/src/client/view-cache.ts +41 -3
- package/src/contract.mjs +4 -3
- package/src/server/handlers.ts +64 -15
- package/src/server/index.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.4 (2026-10-14)
|
|
4
|
+
|
|
5
|
+
- **The owner may decline a report Freeda has not planned.** `FLOW["with-freeda"].next` and `FLOW["planned"].next` now include `"not-now"`, so a report the reading worker cannot plan has an exit instead of showing "With Freeda" for ever. `agentStatus` accepts both moves (they take the approval, as any end does). A copy of the contract vendored elsewhere must be taken again: these are new bytes.
|
|
6
|
+
- **A claim on a closed report answers 409 and never lapses.** `claim-approval` on a report at an end status answers 409 `{ closed: true, status, claimedBy }` to everyone and writes nothing; before, the seven-day approval lapsed and an eight-day-old card could take a shipped or declined report. A report being made stays with the Mac that started it: the move to `being-made` or an end now names the maintainer on its timeline entry (`holder`, not in the member's view) and takes the approval for `HELD_MS` (exported), and another Mac is answered 409 with that holder however long after. Before the work starts the approval still lapses after a week.
|
|
7
|
+
- **The list never offers a report another maintainer closed.** `agentList` settles such a report for the asking maintainer without showing it, so it neither raises a notice on a second Mac nor fills the head of every page; it asks the store again when that freed slots. A report another maintainer is making carries `heldBy`. A report closed before 0.1.4 names no holder and is treated as closed by someone else.
|
|
8
|
+
|
|
9
|
+
## 0.1.3 (2026-10-13)
|
|
10
|
+
|
|
11
|
+
- **The phone's saved answers belong to whoever asked.** A list or report that the app answers after the signed-in account has changed is neither saved under the earlier account's key nor drawn: the account is asked again after the answer, as the outbox does before a write.
|
|
12
|
+
- **A refusal is not "could not reach the app".** `mine()` and `one(id)` signal a refusal by throwing an error with `refused: true` or a `status` of 401, 403 or 404 (`one` may also answer an object with a string `refused`). On a refusal the saved copy is deleted and the screen says the suggestion could not be opened (or the list could not be loaded); any other failure keeps the saved copy under the "Could not reach the app" line, as in 0.1.2. Documented on `StoreAdapter`.
|
|
13
|
+
- **The detail view follows its `id`.** Changing the id prop resets what is shown, so one report is never drawn under another's address.
|
|
14
|
+
- **The saved views are kept tidy.** After a successful list, saved reports the app no longer lists are deleted, and signing out clears the account's saved views from the phone.
|
|
15
|
+
- "Updating" shows only until the first answer after the screen opens, not on every periodic reload.
|
|
16
|
+
|
|
3
17
|
## 0.1.2 (2026-10-13)
|
|
4
18
|
|
|
5
19
|
- **My suggestions and the detail view draw at once.** Each keeps its last successful answer (`mine()` and each `one(id)`) in the module's own IndexedDB store, keyed by app and account like a draft, and draws it as soon as the phone has read it, with a quiet "Updating" line until the app answers. The app's answer replaces it (a report that vanished on the app disappears). If the app cannot be reached, the saved rows stay and the screen says so: "Could not reach the app. Showing what this phone last saw." A first open with nothing saved shows the loading state, as before. Reports waiting in the outbox show as they did. Another account never reads this account's saved answer. New strings `updating` and `offlineShowingSaved` (replaceable through `words.text`).
|
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ npm install @supersuit/suggest-a-change
|
|
|
12
12
|
import { KINDS, STATUSES, FLOW, CEILINGS } from "@supersuit/suggest-a-change";
|
|
13
13
|
|
|
14
14
|
console.log(KINDS); // ["change", "broken", "feedback"]
|
|
15
|
-
console.log(FLOW["with-freeda"]); // { actor: "mac", next: ["planned"] }
|
|
15
|
+
console.log(FLOW["with-freeda"]); // { actor: "mac", next: ["planned", "not-now"] }
|
|
16
16
|
```
|
|
17
17
|
|
|
18
18
|
The package root is the report contract: the kinds, roles, ceilings, the status list and the flow map (who performs each step and which moves are allowed), plus the functions that validate a report against them. It has no imports, so it can also be copied whole into a place that has no install step. If you copy it, keep a hash of the copy and fail a test when the bytes change; fix the contract here and copy it again.
|
|
@@ -47,7 +47,7 @@ const adapters: Adapters = {
|
|
|
47
47
|
// then: {open && <SuggestSheet adapters={adapters} where={{ screen: "Photos", route: location.pathname }} onClose={() => setOpen(false)} />}
|
|
48
48
|
```
|
|
49
49
|
|
|
50
|
-
`SuggestEntry` also takes `className` and `role` (for example `role="menuitem"` when a host shows it as a menu row); no role is set by default. My suggestions and the detail view keep their last answer from the app on the phone (module-owned IndexedDB, keyed by app and account like a draft), draw it at once with a quiet "Updating" line, and replace it when the app answers; if the app cannot be reached they keep the saved rows and say so. A first open with nothing saved shows "Loading".
|
|
50
|
+
`SuggestEntry` also takes `className` and `role` (for example `role="menuitem"` when a host shows it as a menu row); no role is set by default. My suggestions and the detail view keep their last answer from the app on the phone (module-owned IndexedDB, keyed by app and account like a draft), draw it at once with a quiet "Updating" line, and replace it when the app answers; if the app cannot be reached they keep the saved rows and say so. An adapter tells a refusal from an unreachable app: `mine()` and `one()` throw an error with `refused: true` or a `status` of 401, 403 or 404 for a refusal (the saved copy is then deleted), and anything else thrown is unreachable. Saved reports the list no longer holds are pruned, and signing out clears them. A first open with nothing saved shows "Loading".
|
|
51
51
|
|
|
52
52
|
The entry also keeps the phone's outbox moving while it is mounted (a report sent with the Mac away goes when it is back, with the sheet closed); a host with no always-mounted entry renders `<KeepSending adapters={adapters} />` once, high in the app. The entry carries `data-suggest-entry` (the attribute the screen check looks for; see "The check" below). Signed out, it opens nothing and says why. The receipt says "Got it" only after the adapter has answered `done` for the report and every attachment; until then it says the report is on the phone. Every text field the module renders sits in a `[data-voice-field]` container with a `[data-voice-control]`; a host's `Field` slot must keep that shape (it receives `FieldProps`, which is the Helm's voice field props). A voice note alone is a whole report: the recording is kept and uploaded as a part, and the receiving Mac writes the transcript.
|
|
53
53
|
|
|
@@ -173,10 +173,10 @@ const h = createReportHandlers({
|
|
|
173
173
|
| `GET /api/reports/mine` | `mine` | 200, 401 |
|
|
174
174
|
| `GET /api/reports/:id` | `one(req, id)` | 200, 401, 404 (missing and not yours look the same) |
|
|
175
175
|
| `POST /api/reports/:id/append` | `append(req, id)` | 200, 400, 401, 404 (not the owner's: same as missing), 409 (the suggestion has ended) |
|
|
176
|
-
| `GET /api/agent/reports` | `agentList` | 200 `{ reports }`: one page (20, oldest first) of what is open for this Mac, each claimed for ten minutes; a report whose files are still arriving waits up to an hour |
|
|
176
|
+
| `GET /api/agent/reports` | `agentList` | 200 `{ reports }`: one page (20, oldest first) of what is open for this Mac, each claimed for ten minutes; a report whose files are still arriving waits up to an hour; a report another maintainer closed is never offered (it is settled for this Mac unseen); `heldBy` names the maintainer whose Mac is making a report |
|
|
177
177
|
| `GET /api/agent/reports/:id/files/:file` | `agentFile(req, id, file)` | 200 `{ url, expiresAt, type, bytes, sha256 }` (the link lives five minutes), 404 |
|
|
178
178
|
| `POST /api/agent/reports/:id/settle` | `agentSettle` | body `{ revision }` (the one the list gave this report): 200, 400 (no revision), 409 (not claimed, or `stale`: something was added since the list, so the report is offered again) |
|
|
179
|
-
| `POST /api/agent/reports/:id/claim-approval` | `agentClaimApproval` | 200, or 409 `{ claimedBy }` |
|
|
179
|
+
| `POST /api/agent/reports/:id/claim-approval` | `agentClaimApproval` | 200, or 409 `{ claimedBy }`, or on a closed report 409 `{ closed: true, status, claimedBy? }` |
|
|
180
180
|
| `POST /api/agent/reports/:id/status` | `agentStatus` | 200, 400, 409 |
|
|
181
181
|
|
|
182
182
|
A client adapter's `uploadPart` asks `upload-url`, PUTs the bytes straight to storage, then calls `finalize`. Nothing is uploaded through the app's server and no handler reads a body over about 400 KB (sized from the field limits at four bytes a character; far under 4 MB).
|
|
@@ -187,7 +187,7 @@ The role stored on a report comes from the signed-in member and the `maintainers
|
|
|
187
187
|
|
|
188
188
|
**The collector MUST verify.** `agentFile` answers the verified `bytes` and `sha256`; a Mac that downloads the link must check both before it trusts the file.
|
|
189
189
|
|
|
190
|
-
**The approval.** Moves before the owner is asked (`with-freeda`, `planned`, `waiting-on-owner`) need no claim. Leaving `waiting-on-owner`, starting `being-made` and any end status need the Mac that holds the approval (taken atomically if nobody holds it, otherwise 409 with who does).
|
|
190
|
+
**The approval.** Moves before the owner is asked (`with-freeda`, `planned`, `waiting-on-owner`) need no claim. Leaving `waiting-on-owner`, starting `being-made` and any end status need the Mac that holds the approval (taken atomically if nobody holds it, otherwise 409 with who does). Before the work starts the holder can release it, and an unrenewed approval lapses after seven days. Once the work starts or ends it does not lapse: the move to `being-made` or to an end names the maintainer on its timeline entry (`holder`, never shown to the member), the approval is taken for `HELD_MS`, and another Mac asking is answered 409 with that holder. A claim on a closed report is 409 `{ closed: true, status, claimedBy }` for everyone, the holder included, and writes nothing. The owner may decline (`not-now`) from `with-freeda` and `planned` as well as from `waiting-on-owner`.
|
|
191
191
|
|
|
192
192
|
**What a storage adapter implements** (`HostedStore`). Every method that changes a report is narrow and atomic: `put` is create-only (`created`, `exists`, or `limit` when the member's day is full, counted in the same transaction), and `setFile` (never replaces a verified entry; an older pending never replaces a newer one), `addFollowUp` (the caps, a reused file name and an ended report are checked inside the write), `setStatus(expectFrom)`, `claimApproval`, `claim`, `settle` and `reopen` each change one thing. A real adapter implements these with a transaction or a precondition, never "read the report, change a copy, write it back": that rolls back a status or a claim written in between. Upload links must be create-only and one-time with the size and type fixed in their conditions (a Firebase or S3 signed PUT with `if-generation-match: 0` or `If-None-Match: *`), `discard` must delete a refused object, and `readUrl` links must expire after `ttlMs`.
|
|
193
193
|
|
package/dist/client/detail.js
CHANGED
|
@@ -11,7 +11,7 @@ import { recoverTakes } from './recorder.js';
|
|
|
11
11
|
import { queueFollowUp } from './reports.js';
|
|
12
12
|
import { useRuntime, useTick } from './runtime.js';
|
|
13
13
|
import { Toasts } from './toast.js';
|
|
14
|
-
import { oneCacheKey, readCache, writeCache } from './view-cache.js';
|
|
14
|
+
import { dropCache, isRefusal, oneCacheKey, readCache, useClearOnSignOut, writeCache } from './view-cache.js';
|
|
15
15
|
import { effectiveCeilings, lengthWords, sizeWords, statusMeaning, statusWords, stringsFor } from './words.js';
|
|
16
16
|
export function statusOf(r) {
|
|
17
17
|
if (typeof r.status === 'string')
|
|
@@ -96,7 +96,8 @@ export function SuggestionDetail({ adapters, id }) {
|
|
|
96
96
|
// Anything that writes the follow-up waits until its draft has been read, so the counter is right.
|
|
97
97
|
const restored = useRef(makeDeferred());
|
|
98
98
|
// A different account (or none) shows nothing of the last one.
|
|
99
|
-
|
|
99
|
+
useClearOnSignOut(rt, who, adapters.words.app);
|
|
100
|
+
useEffect(() => { loads.current++; shown.current = false; setUpdating(false); setOffline(false); setReport(undefined); setPending([]); setReady(false); setMore(''); setHeard([]); followId.current = null; n.current = 0; restored.current = makeDeferred(); }, [rt, id]);
|
|
100
101
|
const load = useCallback(async () => {
|
|
101
102
|
if (!rt)
|
|
102
103
|
return;
|
|
@@ -127,14 +128,30 @@ export function SuggestionDetail({ adapters, id }) {
|
|
|
127
128
|
}
|
|
128
129
|
}
|
|
129
130
|
let r;
|
|
131
|
+
let refused = false;
|
|
130
132
|
try {
|
|
131
|
-
|
|
133
|
+
const a = (await adapters.store.one(id));
|
|
134
|
+
if (a && typeof a === 'object' && typeof a.refused === 'string')
|
|
135
|
+
refused = true;
|
|
136
|
+
else
|
|
137
|
+
r = a;
|
|
132
138
|
}
|
|
133
|
-
catch {
|
|
139
|
+
catch (e) {
|
|
134
140
|
r = undefined;
|
|
141
|
+
refused = isRefusal(e);
|
|
142
|
+
}
|
|
143
|
+
// The answer belongs to whoever asked: if the signed-in account is no longer that one, nothing is kept or drawn.
|
|
144
|
+
let now = null;
|
|
145
|
+
try {
|
|
146
|
+
now = await adapters.identity();
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
now = null;
|
|
135
150
|
}
|
|
136
151
|
if (!fresh())
|
|
137
152
|
return;
|
|
153
|
+
if (!now || now.account !== rt.who.account)
|
|
154
|
+
return;
|
|
138
155
|
setUpdating(false);
|
|
139
156
|
if (r) {
|
|
140
157
|
shown.current = true;
|
|
@@ -142,6 +159,12 @@ export function SuggestionDetail({ adapters, id }) {
|
|
|
142
159
|
setReport(r);
|
|
143
160
|
void writeCache(rt.store, ckey, r);
|
|
144
161
|
}
|
|
162
|
+
else if (refused) {
|
|
163
|
+
shown.current = false;
|
|
164
|
+
setOffline(false);
|
|
165
|
+
void dropCache(rt.store, ckey);
|
|
166
|
+
setReport(null);
|
|
167
|
+
}
|
|
145
168
|
else if (shown.current)
|
|
146
169
|
setOffline(true);
|
|
147
170
|
else
|
package/dist/client/mine.js
CHANGED
|
@@ -7,7 +7,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
|
|
|
7
7
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
8
8
|
import { SuggestionDetail } from './detail.js';
|
|
9
9
|
import { useRuntime, useTick } from './runtime.js';
|
|
10
|
-
import { mineCacheKey, readCache, writeCache } from './view-cache.js';
|
|
10
|
+
import { dropCache, isRefusal, mineCacheKey, pruneOnes, readCache, useClearOnSignOut, writeCache } from './view-cache.js';
|
|
11
11
|
import { Toasts } from './toast.js';
|
|
12
12
|
import { statusMeaning, statusWords, stringsFor } from './words.js';
|
|
13
13
|
export function MySuggestions({ adapters, onOpen }) {
|
|
@@ -23,7 +23,10 @@ export function MySuggestions({ adapters, onOpen }) {
|
|
|
23
23
|
const loads = useRef(0);
|
|
24
24
|
// What the app last said (or the phone kept of it) for this account; null until either is known.
|
|
25
25
|
const known = useRef(null);
|
|
26
|
-
|
|
26
|
+
// The first answer after mount ends "Updating"; later periodic reloads refresh quietly.
|
|
27
|
+
const answered = useRef(false);
|
|
28
|
+
useClearOnSignOut(rt, who, adapters.words.app);
|
|
29
|
+
useEffect(() => { loads.current++; known.current = null; answered.current = false; setRows(null); setOpen(null); setBad(''); setUpdating(false); }, [rt]);
|
|
27
30
|
const load = useCallback(async () => {
|
|
28
31
|
if (!rt)
|
|
29
32
|
return;
|
|
@@ -49,25 +52,47 @@ export function MySuggestions({ adapters, onOpen }) {
|
|
|
49
52
|
if (mine_ !== loads.current)
|
|
50
53
|
return;
|
|
51
54
|
paint(known.current, local);
|
|
52
|
-
|
|
55
|
+
if (!answered.current)
|
|
56
|
+
setUpdating(true);
|
|
53
57
|
}
|
|
54
58
|
let theirs = null;
|
|
59
|
+
let refused = false;
|
|
55
60
|
try {
|
|
56
61
|
theirs = await adapters.store.mine();
|
|
57
62
|
}
|
|
58
|
-
catch {
|
|
63
|
+
catch (e) {
|
|
59
64
|
theirs = null;
|
|
65
|
+
refused = isRefusal(e);
|
|
66
|
+
}
|
|
67
|
+
// The answer belongs to whoever asked. If the signed-in account is no longer that one, nothing is kept or drawn.
|
|
68
|
+
let now = null;
|
|
69
|
+
try {
|
|
70
|
+
now = await adapters.identity();
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
now = null;
|
|
60
74
|
}
|
|
61
75
|
const local = await rt.waiting();
|
|
62
76
|
if (mine_ !== loads.current)
|
|
63
77
|
return;
|
|
78
|
+
if (!now || now.account !== rt.who.account)
|
|
79
|
+
return;
|
|
80
|
+
answered.current = true;
|
|
64
81
|
setUpdating(false);
|
|
65
82
|
if (theirs) {
|
|
66
83
|
known.current = theirs;
|
|
67
84
|
void writeCache(rt.store, key, theirs);
|
|
85
|
+
void pruneOnes(rt.store, { app: adapters.words.app, account: rt.who.account }, theirs.map((t) => t.id));
|
|
68
86
|
setBad('');
|
|
69
87
|
paint(theirs, local);
|
|
70
88
|
}
|
|
89
|
+
else if (refused) {
|
|
90
|
+
// The app said no: what the phone held is no longer this person's to see.
|
|
91
|
+
known.current = null;
|
|
92
|
+
void dropCache(rt.store, key);
|
|
93
|
+
setBad(S.couldNotLoadMine);
|
|
94
|
+
paint([], local);
|
|
95
|
+
}
|
|
71
96
|
else if (known.current !== null) {
|
|
72
97
|
setBad(S.offlineShowingSaved);
|
|
73
98
|
paint(known.current, local);
|
package/dist/client/reports.d.ts
CHANGED
|
@@ -22,7 +22,9 @@ export interface StoreAdapter {
|
|
|
22
22
|
uploadPart(reportId: string, file: string, part: number, bytes: Blob): Promise<'done' | {
|
|
23
23
|
refused: string;
|
|
24
24
|
}>;
|
|
25
|
+
/** An unreachable app THROWS (a network error). A refusal (the list is no longer this person's) throws an error with `refused: true` or a `status` of 401, 403 or 404: the phone then drops what it kept. */
|
|
25
26
|
mine(): Promise<Summary[]>;
|
|
27
|
+
/** Same contract as `mine`: a report that is gone or not this person's is a refusal (`refused: true`, or status 401/403/404, or an answer object with a string `refused`), never a plain failure, so the phone stops showing its saved copy. */
|
|
26
28
|
one(id: string): Promise<Report>;
|
|
27
29
|
/** Adds to a report. Also at-least-once: it must dedupe by (id, `more.followId`). */
|
|
28
30
|
append(id: string, more: Record<string, unknown>): Promise<'done' | {
|
|
@@ -1,11 +1,26 @@
|
|
|
1
1
|
import type { Store } from './idb.ts';
|
|
2
|
-
|
|
2
|
+
type Id = {
|
|
3
3
|
app: string;
|
|
4
4
|
account: string;
|
|
5
|
-
}
|
|
6
|
-
export declare const
|
|
7
|
-
|
|
8
|
-
account: string;
|
|
9
|
-
}, reportId: string) => string;
|
|
5
|
+
};
|
|
6
|
+
export declare const mineCacheKey: (id: Id) => string;
|
|
7
|
+
export declare const oneCacheKey: (id: Id, reportId: string) => string;
|
|
10
8
|
export declare function readCache<T>(store: Store, key: string): Promise<T | undefined>;
|
|
11
9
|
export declare function writeCache(store: Store, key: string, value: unknown): Promise<void>;
|
|
10
|
+
export declare function dropCache(store: Store, key: string): Promise<void>;
|
|
11
|
+
/** After a successful list: cached reports the app no longer lists (deleted, or no longer this person's) are dropped. */
|
|
12
|
+
export declare function pruneOnes(store: Store, id: Id, keep: string[]): Promise<void>;
|
|
13
|
+
/** Everything cached for one account: the list and every report. */
|
|
14
|
+
export declare function clearViewCache(store: Store, id: Id): Promise<void>;
|
|
15
|
+
/** True when the app REFUSED (the report is gone, or this person may not see it), as against being unreachable.
|
|
16
|
+
* The adapter contract: `mine()` and `one()` throw an error carrying `refused: true`, or a `status` of 401, 403 or 404;
|
|
17
|
+
* `one()` may instead answer an object with a string `refused`. Anything else thrown is a failure to reach the app. */
|
|
18
|
+
export declare function isRefusal(e: unknown): boolean;
|
|
19
|
+
/** When the person signs out (identity becomes null), the account that was signed in loses its cached views on this phone. */
|
|
20
|
+
export declare function useClearOnSignOut(rt: {
|
|
21
|
+
store: Store;
|
|
22
|
+
who: {
|
|
23
|
+
account: string;
|
|
24
|
+
};
|
|
25
|
+
} | null, who: unknown, app: string): void;
|
|
26
|
+
export {};
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
'use client';
|
|
1
2
|
// THE LAST ANSWER, KEPT ON THE PHONE. My suggestions and the detail view draw from this at once and then
|
|
2
3
|
// refresh from the app, so opening them never waits on the app being reachable. The key names the app and
|
|
3
4
|
// the account exactly as a draft's does, so one person's cache is never read for another.
|
|
5
|
+
import { useEffect, useRef } from 'react';
|
|
4
6
|
import { draftKey } from './drafts.js';
|
|
5
7
|
const base = (id) => `${draftKey(id)}#view`;
|
|
6
8
|
export const mineCacheKey = (id) => `${base(id)}:mine`;
|
|
@@ -19,3 +21,52 @@ export async function writeCache(store, key, value) {
|
|
|
19
21
|
}
|
|
20
22
|
catch { /* a full or blocked database only costs the next open its head start */ }
|
|
21
23
|
}
|
|
24
|
+
export async function dropCache(store, key) {
|
|
25
|
+
try {
|
|
26
|
+
await store.del('kv', key);
|
|
27
|
+
}
|
|
28
|
+
catch { /* nothing to drop */ }
|
|
29
|
+
}
|
|
30
|
+
/** After a successful list: cached reports the app no longer lists (deleted, or no longer this person's) are dropped. */
|
|
31
|
+
export async function pruneOnes(store, id, keep) {
|
|
32
|
+
try {
|
|
33
|
+
const prefix = `${base(id)}:one:`;
|
|
34
|
+
const live = new Set(keep);
|
|
35
|
+
for (const row of await store.all('kv', prefix))
|
|
36
|
+
if (!live.has(row.key.slice(prefix.length)))
|
|
37
|
+
await store.del('kv', row.key);
|
|
38
|
+
}
|
|
39
|
+
catch { /* the next list prunes again */ }
|
|
40
|
+
}
|
|
41
|
+
/** Everything cached for one account: the list and every report. */
|
|
42
|
+
export async function clearViewCache(store, id) {
|
|
43
|
+
try {
|
|
44
|
+
for (const row of await store.all('kv', base(id)))
|
|
45
|
+
await store.del('kv', row.key);
|
|
46
|
+
}
|
|
47
|
+
catch { /* best effort */ }
|
|
48
|
+
}
|
|
49
|
+
/** True when the app REFUSED (the report is gone, or this person may not see it), as against being unreachable.
|
|
50
|
+
* The adapter contract: `mine()` and `one()` throw an error carrying `refused: true`, or a `status` of 401, 403 or 404;
|
|
51
|
+
* `one()` may instead answer an object with a string `refused`. Anything else thrown is a failure to reach the app. */
|
|
52
|
+
export function isRefusal(e) {
|
|
53
|
+
if (!e || typeof e !== 'object')
|
|
54
|
+
return false;
|
|
55
|
+
const x = e;
|
|
56
|
+
return x.refused === true || (typeof x.refused === 'string' && x.refused !== '') || x.status === 401 || x.status === 403 || x.status === 404;
|
|
57
|
+
}
|
|
58
|
+
/** When the person signs out (identity becomes null), the account that was signed in loses its cached views on this phone. */
|
|
59
|
+
export function useClearOnSignOut(rt, who, app) {
|
|
60
|
+
const last = useRef(null);
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (rt) {
|
|
63
|
+
last.current = { store: rt.store, id: { app, account: rt.who.account } };
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (who === null && last.current) {
|
|
67
|
+
const l = last.current;
|
|
68
|
+
last.current = null;
|
|
69
|
+
void clearViewCache(l.store, l.id);
|
|
70
|
+
}
|
|
71
|
+
}, [rt, who, app]);
|
|
72
|
+
}
|
package/dist/contract.mjs
CHANGED
|
@@ -14,12 +14,13 @@ export const CEILINGS = {
|
|
|
14
14
|
export const STATUSES = ["on-phone", "received", "with-freeda", "planned", "waiting-on-owner", "being-made", "live", "sent-to-template", "filed", "not-now"];
|
|
15
15
|
export const END = ["live", "sent-to-template", "filed", "not-now"];
|
|
16
16
|
// actor: who PERFORMS this step (design section 7): sent by the person, received by the app, with Freeda by the Mac. A Helm on the owner's Mac posts straight into
|
|
17
|
-
// the inbox, so a report may go from the phone to Freeda without the hosted app's "received".
|
|
17
|
+
// the inbox, so a report may go from the phone to Freeda without the hosted app's "received". The owner may decline
|
|
18
|
+
// ("not-now") before Freeda has a plan or before she asks: a report she cannot plan would otherwise have no exit.
|
|
18
19
|
export const FLOW = {
|
|
19
20
|
"on-phone": { actor: "person", next: ["received", "with-freeda"] },
|
|
20
21
|
"received": { actor: "app", next: ["with-freeda"] },
|
|
21
|
-
"with-freeda": { actor: "mac", next: ["planned"] },
|
|
22
|
-
"planned": { actor: "freeda", next: ["waiting-on-owner", "being-made"] },
|
|
22
|
+
"with-freeda": { actor: "mac", next: ["planned", "not-now"] },
|
|
23
|
+
"planned": { actor: "freeda", next: ["waiting-on-owner", "being-made", "not-now"] },
|
|
23
24
|
"waiting-on-owner": { actor: "owner", next: ["being-made", "filed", "not-now"] },
|
|
24
25
|
"being-made": { actor: "freeda", next: ["live", "sent-to-template", "filed", "not-now"] },
|
|
25
26
|
"live": { actor: "freeda", next: [] },
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { type Limits } from './rate.ts';
|
|
2
|
+
/** `by` is the actor (contract FLOW). `holder` is the maintainer whose Mac started or ended the work, written on a being-made
|
|
3
|
+
* or end entry: the approval on such a report never lapses, and it is how the list and the claim name who has it. */
|
|
2
4
|
export interface TimelineEntry {
|
|
3
5
|
status: string;
|
|
4
6
|
by: string;
|
|
5
7
|
at: string;
|
|
6
8
|
step?: string;
|
|
7
9
|
note?: string;
|
|
10
|
+
holder?: string;
|
|
8
11
|
}
|
|
9
12
|
export interface FileState {
|
|
10
13
|
pending?: {
|
|
@@ -155,6 +158,8 @@ export interface ReportHandlerOptions {
|
|
|
155
158
|
export declare const LEASE_MS: number;
|
|
156
159
|
export declare const BODY_CAP: number;
|
|
157
160
|
export declare const APPROVAL_LEASE_MS: number;
|
|
161
|
+
/** The approval's lease once the work has started or ended: in effect it never lapses (finite, so every store can keep it). */
|
|
162
|
+
export declare const HELD_MS: number;
|
|
158
163
|
export declare const READ_TTL_MS: number;
|
|
159
164
|
export declare const VOICE_FILE_BYTES: number;
|
|
160
165
|
/** A voice file may be this many bytes per declared second (768 kbit/s, generous for any recorder) plus a little slack: ten seconds cannot claim 25 MB. */
|
package/dist/server/handlers.js
CHANGED
|
@@ -23,6 +23,8 @@ const WORDS_MAX = 20_000;
|
|
|
23
23
|
// A maximal report in Chinese or emoji fits; the whole is still a small fraction of the 4 MB no handler may exceed.
|
|
24
24
|
export const BODY_CAP = 4 * WORDS_MAX * 4 + 64 * 1024;
|
|
25
25
|
export const APPROVAL_LEASE_MS = 7 * 24 * 3600_000;
|
|
26
|
+
/** The approval's lease once the work has started or ended: in effect it never lapses (finite, so every store can keep it). */
|
|
27
|
+
export const HELD_MS = 100 * 365 * 24 * 3600_000;
|
|
26
28
|
export const READ_TTL_MS = 5 * 60_000;
|
|
27
29
|
export const VOICE_FILE_BYTES = 25 * 2 ** 20;
|
|
28
30
|
/** A voice file may be this many bytes per declared second (768 kbit/s, generous for any recorder) plus a little slack: ten seconds cannot claim 25 MB. */
|
|
@@ -74,6 +76,15 @@ async function readBody(req, cap = BODY_CAP, optional = false) {
|
|
|
74
76
|
}
|
|
75
77
|
const iso = (ms) => new Date(ms).toISOString();
|
|
76
78
|
const lastStatus = (r) => r.timeline[r.timeline.length - 1]?.status ?? 'received';
|
|
79
|
+
/** Past asking the owner: the work has started or ended, and whoever holds the approval keeps it. */
|
|
80
|
+
const PAST_ASKING = ['being-made', ...END];
|
|
81
|
+
/** The maintainer named on the latest move that started or ended the work; none on a report from before 0.1.4. */
|
|
82
|
+
const holderOf = (r) => {
|
|
83
|
+
for (let i = r.timeline.length - 1; i >= 0; i--)
|
|
84
|
+
if (r.timeline[i].holder)
|
|
85
|
+
return r.timeline[i].holder;
|
|
86
|
+
return undefined;
|
|
87
|
+
};
|
|
77
88
|
const str = (v, max) => (typeof v === 'string' && v.length <= max ? v : null);
|
|
78
89
|
const fileNames = (r) => [
|
|
79
90
|
...(r.media ?? []).map((m) => m.file),
|
|
@@ -400,17 +411,36 @@ export function createReportHandlers(opts) {
|
|
|
400
411
|
return signIn();
|
|
401
412
|
const out = [];
|
|
402
413
|
// A page, oldest first. Reports from before this maintainer was added are skipped when `maintainersSince` says so.
|
|
403
|
-
|
|
404
|
-
|
|
414
|
+
// A report another maintainer already closed is never offered: it is settled for this maintainer here, unseen, so
|
|
415
|
+
// it neither raises a notice on this Mac nor sits at the head of every page. Settling one frees a slot, so the
|
|
416
|
+
// store is asked again (a few times at most) until the page fills or nothing more was settled.
|
|
417
|
+
for (let round = 0; round < 4 && out.length < limits.agentPage; round++) {
|
|
418
|
+
const batch = await store.listOpenFor(by, { limit: limits.agentPage * 3, since: since(by) });
|
|
419
|
+
let settled = 0;
|
|
420
|
+
for (const r of batch) {
|
|
421
|
+
if (out.length >= limits.agentPage)
|
|
422
|
+
break;
|
|
423
|
+
if (END.includes(lastStatus(r)) && holderOf(r) !== by) {
|
|
424
|
+
await store.claim(r.id, by, LEASE_MS);
|
|
425
|
+
if ((await store.settle(r.id, by, reportRevision(r))) === 'settled')
|
|
426
|
+
settled++;
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
// A report still waiting for its attachments is held back, so the Mac never copies half of one.
|
|
430
|
+
if (!complete(r) && now() - Date.parse(r.receivedAt) < INCOMPLETE_GRACE_MS)
|
|
431
|
+
continue;
|
|
432
|
+
await store.claim(r.id, by, LEASE_MS);
|
|
433
|
+
out.push(r);
|
|
434
|
+
}
|
|
435
|
+
if (!settled || batch.length < limits.agentPage * 3)
|
|
405
436
|
break;
|
|
406
|
-
// A report still waiting for its attachments is held back, so the Mac never copies half of one.
|
|
407
|
-
if (!complete(r) && now() - Date.parse(r.receivedAt) < INCOMPLETE_GRACE_MS)
|
|
408
|
-
continue;
|
|
409
|
-
await store.claim(r.id, by, LEASE_MS);
|
|
410
|
-
out.push(r);
|
|
411
437
|
}
|
|
412
438
|
// `revision` is what this Mac is being shown; it goes back with the settle so a report that grew in between is not settled.
|
|
413
|
-
|
|
439
|
+
// `heldBy` names another maintainer whose Mac is making the report, so this Mac files it as taken.
|
|
440
|
+
return json(200, { reports: out.map((r) => {
|
|
441
|
+
const holder = lastStatus(r) === 'being-made' ? holderOf(r) : undefined;
|
|
442
|
+
return { ...r, revision: reportRevision(r), ...(holder && holder !== by ? { heldBy: holder } : {}) };
|
|
443
|
+
}) });
|
|
414
444
|
}
|
|
415
445
|
async function agentFile(req, id, file) {
|
|
416
446
|
const m = wrongMethod(req, 'GET');
|
|
@@ -455,6 +485,19 @@ export function createReportHandlers(opts) {
|
|
|
455
485
|
await tell({ type: 'settled', reportId: id });
|
|
456
486
|
return json(200, { id, status: moves ? 'with-freeda' : lastStatus(r) });
|
|
457
487
|
}
|
|
488
|
+
/** Takes (or renews) the approval for `by`. A closed report is never taken, by anyone: it answers who closed it. A
|
|
489
|
+
* report being made stays with the Mac that started it. Taken for a move that starts or ends the work, or on a
|
|
490
|
+
* report already being made, the lease is HELD_MS, so it does not lapse; before that, a week. Writes nothing when refused. */
|
|
491
|
+
async function takeApproval(r, by, to) {
|
|
492
|
+
const from = lastStatus(r);
|
|
493
|
+
const holder = holderOf(r);
|
|
494
|
+
if (END.includes(from))
|
|
495
|
+
return { closed: from, ...(holder ? { claimedBy: holder } : {}) };
|
|
496
|
+
if (from === 'being-made' && holder && holder !== by)
|
|
497
|
+
return { claimedBy: holder };
|
|
498
|
+
const lasting = from === 'being-made' || (to !== undefined && PAST_ASKING.includes(to));
|
|
499
|
+
return store.claimApproval(r.id, by, lasting ? HELD_MS : APPROVAL_LEASE_MS);
|
|
500
|
+
}
|
|
458
501
|
async function agentClaimApproval(req, id) {
|
|
459
502
|
const m = wrongMethod(req, 'POST');
|
|
460
503
|
if (m)
|
|
@@ -465,9 +508,12 @@ export function createReportHandlers(opts) {
|
|
|
465
508
|
const body = await readBody(req, BODY_CAP, true);
|
|
466
509
|
if ('res' in body)
|
|
467
510
|
return body.res;
|
|
468
|
-
|
|
511
|
+
const r = isReportId(id) ? await store.get(id) : null;
|
|
512
|
+
if (!r)
|
|
469
513
|
return notFound();
|
|
470
|
-
const got = await
|
|
514
|
+
const got = await takeApproval(r, by);
|
|
515
|
+
if ('closed' in got)
|
|
516
|
+
return json(409, { error: 'this suggestion is closed', closed: true, status: got.closed, ...(got.claimedBy ? { claimedBy: got.claimedBy } : {}) });
|
|
471
517
|
return 'ok' in got ? json(200, { ok: true, claimedBy: by }) : json(409, { claimedBy: got.claimedBy });
|
|
472
518
|
}
|
|
473
519
|
async function agentReleaseApproval(req, id) {
|
|
@@ -511,14 +557,21 @@ export function createReportHandlers(opts) {
|
|
|
511
557
|
return json(409, { error: `a report cannot go from ${from} to ${status}` });
|
|
512
558
|
// Once the owner is being asked, the Mac that holds the approval decides what happens next. Leaving
|
|
513
559
|
// waiting-on-owner, starting the work and ending it need the claim (taken here if nobody holds it).
|
|
514
|
-
|
|
515
|
-
|
|
560
|
+
const needsApproval = from === 'waiting-on-owner' || PAST_ASKING.includes(status);
|
|
561
|
+
if (needsApproval) {
|
|
562
|
+
const c = await takeApproval(r, by, status);
|
|
516
563
|
if (!('ok' in c))
|
|
517
|
-
return json(409, { error: 'another Mac holds the approval for this report', claimedBy: c.claimedBy });
|
|
564
|
+
return json(409, { error: 'another Mac holds the approval for this report', ...(c.claimedBy ? { claimedBy: c.claimedBy } : {}) });
|
|
518
565
|
}
|
|
519
|
-
|
|
520
|
-
|
|
566
|
+
// The maintainer is named on a move that starts or ends the work, so the hold is known from the report itself.
|
|
567
|
+
const holder = PAST_ASKING.includes(status) ? { holder: by } : {};
|
|
568
|
+
const moved = await store.setStatus(id, { status, by: FLOW[status].actor, at: iso(now()), ...(step ? { step } : {}), ...(note ? { note } : {}), ...holder }, from);
|
|
569
|
+
if (!moved) {
|
|
570
|
+
// The lasting lease was taken for a move that did not happen: bring it back to a week.
|
|
571
|
+
if (needsApproval && from !== 'being-made' && PAST_ASKING.includes(status))
|
|
572
|
+
await store.claimApproval(id, by, APPROVAL_LEASE_MS);
|
|
521
573
|
return json(409, { error: 'the report changed while this was sent; read it again' });
|
|
574
|
+
}
|
|
522
575
|
await tell({ type: 'status', reportId: id });
|
|
523
576
|
return json(200, { id, status });
|
|
524
577
|
}
|
package/dist/server/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { createReportHandlers, reportRevision, VOICE_BYTES_PER_SECOND, LEASE_MS, BODY_CAP, APPROVAL_LEASE_MS, READ_TTL_MS, INCOMPLETE_GRACE_MS, VOICE_FILE_BYTES } from './handlers.ts';
|
|
1
|
+
export { createReportHandlers, reportRevision, VOICE_BYTES_PER_SECOND, LEASE_MS, BODY_CAP, APPROVAL_LEASE_MS, HELD_MS, READ_TTL_MS, INCOMPLETE_GRACE_MS, VOICE_FILE_BYTES } from './handlers.ts';
|
|
2
2
|
export type { HostedStore, StoredReport, TimelineEntry, FileState, FollowUp, TellEvent, ReportHandlerOptions } from './handlers.ts';
|
|
3
3
|
export { sniffType, sameType, canonicalType, ACCEPTED_TYPES } from './sniff.ts';
|
|
4
4
|
export { effectiveLimits } from './rate.ts';
|
package/dist/server/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// "@supersuit/suggest-a-change/server": web-standard APIs only, no runtime dependency, nothing from react or the client.
|
|
2
|
-
export { createReportHandlers, reportRevision, VOICE_BYTES_PER_SECOND, LEASE_MS, BODY_CAP, APPROVAL_LEASE_MS, READ_TTL_MS, INCOMPLETE_GRACE_MS, VOICE_FILE_BYTES } from './handlers.js';
|
|
2
|
+
export { createReportHandlers, reportRevision, VOICE_BYTES_PER_SECOND, LEASE_MS, BODY_CAP, APPROVAL_LEASE_MS, HELD_MS, READ_TTL_MS, INCOMPLETE_GRACE_MS, VOICE_FILE_BYTES } from './handlers.js';
|
|
3
3
|
export { sniffType, sameType, canonicalType, ACCEPTED_TYPES } from './sniff.js';
|
|
4
4
|
export { effectiveLimits } from './rate.js';
|
|
5
5
|
export { runStoreConformance, checks as conformanceChecks } from './conformance.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supersuit/suggest-a-change",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "The report format and flow behind a Suggest a change button: what a report is, who acts at each step, the screens, and the check every app runs. The contract has zero dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/client/detail.tsx
CHANGED
|
@@ -11,7 +11,7 @@ import { recoverTakes } from './recorder.ts'
|
|
|
11
11
|
import { queueFollowUp, type Report } from './reports.ts'
|
|
12
12
|
import { useRuntime, useTick, type FollowUp } from './runtime.ts'
|
|
13
13
|
import { Toasts } from './toast.tsx'
|
|
14
|
-
import { oneCacheKey, readCache, writeCache } from './view-cache.ts'
|
|
14
|
+
import { dropCache, isRefusal, oneCacheKey, readCache, useClearOnSignOut, writeCache } from './view-cache.ts'
|
|
15
15
|
import { effectiveCeilings, lengthWords, sizeWords, statusMeaning, statusWords, stringsFor } from './words.ts'
|
|
16
16
|
|
|
17
17
|
export function statusOf(r: Record<string, unknown>): string {
|
|
@@ -103,7 +103,8 @@ export function SuggestionDetail({ adapters, id }: { adapters: Adapters; id: str
|
|
|
103
103
|
const restored = useRef(makeDeferred())
|
|
104
104
|
|
|
105
105
|
// A different account (or none) shows nothing of the last one.
|
|
106
|
-
|
|
106
|
+
useClearOnSignOut(rt, who, adapters.words.app)
|
|
107
|
+
useEffect(() => { loads.current++; shown.current = false; setUpdating(false); setOffline(false); setReport(undefined); setPending([]); setReady(false); setMore(''); setHeard([]); followId.current = null; n.current = 0; restored.current = makeDeferred() }, [rt, id])
|
|
107
108
|
|
|
108
109
|
const load = useCallback(async () => {
|
|
109
110
|
if (!rt) return
|
|
@@ -124,10 +125,20 @@ export function SuggestionDetail({ adapters, id }: { adapters: Adapters; id: str
|
|
|
124
125
|
if (saved && typeof saved === 'object') { shown.current = true; setReport(saved); setUpdating(true) }
|
|
125
126
|
}
|
|
126
127
|
let r: (Report & Record<string, unknown>) | undefined
|
|
127
|
-
|
|
128
|
+
let refused = false
|
|
129
|
+
try {
|
|
130
|
+
const a = (await adapters.store.one(id)) as unknown
|
|
131
|
+
if (a && typeof a === 'object' && typeof (a as { refused?: unknown }).refused === 'string') refused = true
|
|
132
|
+
else r = a as Report & Record<string, unknown>
|
|
133
|
+
} catch (e) { r = undefined; refused = isRefusal(e) }
|
|
134
|
+
// The answer belongs to whoever asked: if the signed-in account is no longer that one, nothing is kept or drawn.
|
|
135
|
+
let now: { account: string } | null = null
|
|
136
|
+
try { now = await adapters.identity() } catch { now = null }
|
|
128
137
|
if (!fresh()) return
|
|
138
|
+
if (!now || now.account !== rt.who.account) return
|
|
129
139
|
setUpdating(false)
|
|
130
140
|
if (r) { shown.current = true; setOffline(false); setReport(r); void writeCache(rt.store, ckey, r) }
|
|
141
|
+
else if (refused) { shown.current = false; setOffline(false); void dropCache(rt.store, ckey); setReport(null) }
|
|
131
142
|
else if (shown.current) setOffline(true)
|
|
132
143
|
else setReport(null)
|
|
133
144
|
} catch { if (fresh()) setReport(null) }
|
package/src/client/mine.tsx
CHANGED
|
@@ -8,7 +8,7 @@ import type { Adapters } from './adapters.ts'
|
|
|
8
8
|
import { SuggestionDetail } from './detail.tsx'
|
|
9
9
|
import type { Summary } from './reports.ts'
|
|
10
10
|
import { useRuntime, useTick } from './runtime.ts'
|
|
11
|
-
import { mineCacheKey, readCache, writeCache } from './view-cache.ts'
|
|
11
|
+
import { dropCache, isRefusal, mineCacheKey, pruneOnes, readCache, useClearOnSignOut, writeCache } from './view-cache.ts'
|
|
12
12
|
import { Toasts } from './toast.tsx'
|
|
13
13
|
import { statusMeaning, statusWords, stringsFor } from './words.ts'
|
|
14
14
|
|
|
@@ -28,7 +28,10 @@ export function MySuggestions({ adapters, onOpen }: { adapters: Adapters; onOpen
|
|
|
28
28
|
const loads = useRef(0)
|
|
29
29
|
// What the app last said (or the phone kept of it) for this account; null until either is known.
|
|
30
30
|
const known = useRef<Summary[] | null>(null)
|
|
31
|
-
|
|
31
|
+
// The first answer after mount ends "Updating"; later periodic reloads refresh quietly.
|
|
32
|
+
const answered = useRef(false)
|
|
33
|
+
useClearOnSignOut(rt, who, adapters.words.app)
|
|
34
|
+
useEffect(() => { loads.current++; known.current = null; answered.current = false; setRows(null); setOpen(null); setBad(''); setUpdating(false) }, [rt])
|
|
32
35
|
|
|
33
36
|
const load = useCallback(async () => {
|
|
34
37
|
if (!rt) return
|
|
@@ -51,18 +54,31 @@ export function MySuggestions({ adapters, onOpen }: { adapters: Adapters; onOpen
|
|
|
51
54
|
const local = await rt.waiting()
|
|
52
55
|
if (mine_ !== loads.current) return
|
|
53
56
|
paint(known.current, local)
|
|
54
|
-
setUpdating(true)
|
|
57
|
+
if (!answered.current) setUpdating(true)
|
|
55
58
|
}
|
|
56
59
|
let theirs: Summary[] | null = null
|
|
57
|
-
|
|
60
|
+
let refused = false
|
|
61
|
+
try { theirs = await adapters.store.mine() } catch (e) { theirs = null; refused = isRefusal(e) }
|
|
62
|
+
// The answer belongs to whoever asked. If the signed-in account is no longer that one, nothing is kept or drawn.
|
|
63
|
+
let now: { account: string } | null = null
|
|
64
|
+
try { now = await adapters.identity() } catch { now = null }
|
|
58
65
|
const local = await rt.waiting()
|
|
59
66
|
if (mine_ !== loads.current) return
|
|
67
|
+
if (!now || now.account !== rt.who.account) return
|
|
68
|
+
answered.current = true
|
|
60
69
|
setUpdating(false)
|
|
61
70
|
if (theirs) {
|
|
62
71
|
known.current = theirs
|
|
63
72
|
void writeCache(rt.store, key, theirs)
|
|
73
|
+
void pruneOnes(rt.store, { app: adapters.words.app, account: rt.who.account }, theirs.map((t) => t.id))
|
|
64
74
|
setBad('')
|
|
65
75
|
paint(theirs, local)
|
|
76
|
+
} else if (refused) {
|
|
77
|
+
// The app said no: what the phone held is no longer this person's to see.
|
|
78
|
+
known.current = null
|
|
79
|
+
void dropCache(rt.store, key)
|
|
80
|
+
setBad(S.couldNotLoadMine)
|
|
81
|
+
paint([], local)
|
|
66
82
|
} else if (known.current !== null) {
|
|
67
83
|
setBad(S.offlineShowingSaved)
|
|
68
84
|
paint(known.current, local)
|
package/src/client/reports.ts
CHANGED
|
@@ -21,7 +21,9 @@ export interface Summary { id: string; status: string; kind?: string; at?: numbe
|
|
|
21
21
|
export interface StoreAdapter {
|
|
22
22
|
submit(report: Report): Promise<'done' | { refused: string }>
|
|
23
23
|
uploadPart(reportId: string, file: string, part: number, bytes: Blob): Promise<'done' | { refused: string }>
|
|
24
|
+
/** An unreachable app THROWS (a network error). A refusal (the list is no longer this person's) throws an error with `refused: true` or a `status` of 401, 403 or 404: the phone then drops what it kept. */
|
|
24
25
|
mine(): Promise<Summary[]>
|
|
26
|
+
/** Same contract as `mine`: a report that is gone or not this person's is a refusal (`refused: true`, or status 401/403/404, or an answer object with a string `refused`), never a plain failure, so the phone stops showing its saved copy. */
|
|
25
27
|
one(id: string): Promise<Report>
|
|
26
28
|
/** Adds to a report. Also at-least-once: it must dedupe by (id, `more.followId`). */
|
|
27
29
|
append(id: string, more: Record<string, unknown>): Promise<'done' | { refused: string }>
|
package/src/client/view-cache.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
|
+
'use client'
|
|
1
2
|
// THE LAST ANSWER, KEPT ON THE PHONE. My suggestions and the detail view draw from this at once and then
|
|
2
3
|
// refresh from the app, so opening them never waits on the app being reachable. The key names the app and
|
|
3
4
|
// the account exactly as a draft's does, so one person's cache is never read for another.
|
|
5
|
+
import { useEffect, useRef } from 'react'
|
|
4
6
|
import { draftKey } from './drafts.ts'
|
|
5
7
|
import type { Store } from './idb.ts'
|
|
6
8
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
export const
|
|
9
|
+
type Id = { app: string; account: string }
|
|
10
|
+
const base = (id: Id) => `${draftKey(id)}#view`
|
|
11
|
+
export const mineCacheKey = (id: Id) => `${base(id)}:mine`
|
|
12
|
+
export const oneCacheKey = (id: Id, reportId: string) => `${base(id)}:one:${reportId}`
|
|
10
13
|
|
|
11
14
|
export async function readCache<T>(store: Store, key: string): Promise<T | undefined> {
|
|
12
15
|
try { return await store.get<T>('kv', key) } catch { return undefined }
|
|
@@ -14,3 +17,38 @@ export async function readCache<T>(store: Store, key: string): Promise<T | undef
|
|
|
14
17
|
export async function writeCache(store: Store, key: string, value: unknown): Promise<void> {
|
|
15
18
|
try { await store.put('kv', key, value) } catch { /* a full or blocked database only costs the next open its head start */ }
|
|
16
19
|
}
|
|
20
|
+
export async function dropCache(store: Store, key: string): Promise<void> {
|
|
21
|
+
try { await store.del('kv', key) } catch { /* nothing to drop */ }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** After a successful list: cached reports the app no longer lists (deleted, or no longer this person's) are dropped. */
|
|
25
|
+
export async function pruneOnes(store: Store, id: Id, keep: string[]): Promise<void> {
|
|
26
|
+
try {
|
|
27
|
+
const prefix = `${base(id)}:one:`
|
|
28
|
+
const live = new Set(keep)
|
|
29
|
+
for (const row of await store.all('kv', prefix)) if (!live.has(row.key.slice(prefix.length))) await store.del('kv', row.key)
|
|
30
|
+
} catch { /* the next list prunes again */ }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Everything cached for one account: the list and every report. */
|
|
34
|
+
export async function clearViewCache(store: Store, id: Id): Promise<void> {
|
|
35
|
+
try { for (const row of await store.all('kv', base(id))) await store.del('kv', row.key) } catch { /* best effort */ }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** True when the app REFUSED (the report is gone, or this person may not see it), as against being unreachable.
|
|
39
|
+
* The adapter contract: `mine()` and `one()` throw an error carrying `refused: true`, or a `status` of 401, 403 or 404;
|
|
40
|
+
* `one()` may instead answer an object with a string `refused`. Anything else thrown is a failure to reach the app. */
|
|
41
|
+
export function isRefusal(e: unknown): boolean {
|
|
42
|
+
if (!e || typeof e !== 'object') return false
|
|
43
|
+
const x = e as { refused?: unknown; status?: unknown }
|
|
44
|
+
return x.refused === true || (typeof x.refused === 'string' && x.refused !== '') || x.status === 401 || x.status === 403 || x.status === 404
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** When the person signs out (identity becomes null), the account that was signed in loses its cached views on this phone. */
|
|
48
|
+
export function useClearOnSignOut(rt: { store: Store; who: { account: string } } | null, who: unknown, app: string): void {
|
|
49
|
+
const last = useRef<{ store: Store; id: Id } | null>(null)
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (rt) { last.current = { store: rt.store, id: { app, account: rt.who.account } }; return }
|
|
52
|
+
if (who === null && last.current) { const l = last.current; last.current = null; void clearViewCache(l.store, l.id) }
|
|
53
|
+
}, [rt, who, app])
|
|
54
|
+
}
|
package/src/contract.mjs
CHANGED
|
@@ -18,12 +18,13 @@ export const STATUSES = ["on-phone", "received", "with-freeda", "planned", "wait
|
|
|
18
18
|
export const END = ["live", "sent-to-template", "filed", "not-now"];
|
|
19
19
|
|
|
20
20
|
// actor: who PERFORMS this step (design section 7): sent by the person, received by the app, with Freeda by the Mac. A Helm on the owner's Mac posts straight into
|
|
21
|
-
// the inbox, so a report may go from the phone to Freeda without the hosted app's "received".
|
|
21
|
+
// the inbox, so a report may go from the phone to Freeda without the hosted app's "received". The owner may decline
|
|
22
|
+
// ("not-now") before Freeda has a plan or before she asks: a report she cannot plan would otherwise have no exit.
|
|
22
23
|
export const FLOW = {
|
|
23
24
|
"on-phone": { actor: "person", next: ["received", "with-freeda"] },
|
|
24
25
|
"received": { actor: "app", next: ["with-freeda"] },
|
|
25
|
-
"with-freeda": { actor: "mac", next: ["planned"] },
|
|
26
|
-
"planned": { actor: "freeda", next: ["waiting-on-owner", "being-made"] },
|
|
26
|
+
"with-freeda": { actor: "mac", next: ["planned", "not-now"] },
|
|
27
|
+
"planned": { actor: "freeda", next: ["waiting-on-owner", "being-made", "not-now"] },
|
|
27
28
|
"waiting-on-owner": { actor: "owner", next: ["being-made", "filed", "not-now"] },
|
|
28
29
|
"being-made": { actor: "freeda", next: ["live", "sent-to-template", "filed", "not-now"] },
|
|
29
30
|
"live": { actor: "freeda", next: [] },
|
package/src/server/handlers.ts
CHANGED
|
@@ -11,7 +11,9 @@ import { END, FLOW, STATUSES, isAcceptedMedia, isReportId, mayMove, validateRepo
|
|
|
11
11
|
import { effectiveLimits, type Limits } from './rate.ts'
|
|
12
12
|
import { ACCEPTED_TYPES, canonicalType, sameType } from './sniff.ts'
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
/** `by` is the actor (contract FLOW). `holder` is the maintainer whose Mac started or ended the work, written on a being-made
|
|
15
|
+
* or end entry: the approval on such a report never lapses, and it is how the list and the claim name who has it. */
|
|
16
|
+
export interface TimelineEntry { status: string; by: string; at: string; step?: string; note?: string; holder?: string }
|
|
15
17
|
export interface FileState { pending?: { type: string; bytes: number; issuedAt?: number }; type?: string; bytes?: number; sha256?: string; at?: string }
|
|
16
18
|
export interface FollowUp { followId: string; words: string; at: string; by: string; voice: { file: string; ms: number }[] }
|
|
17
19
|
export interface StoredReport {
|
|
@@ -105,6 +107,8 @@ const WORDS_MAX = 20_000
|
|
|
105
107
|
// A maximal report in Chinese or emoji fits; the whole is still a small fraction of the 4 MB no handler may exceed.
|
|
106
108
|
export const BODY_CAP = 4 * WORDS_MAX * 4 + 64 * 1024
|
|
107
109
|
export const APPROVAL_LEASE_MS = 7 * 24 * 3600_000
|
|
110
|
+
/** The approval's lease once the work has started or ended: in effect it never lapses (finite, so every store can keep it). */
|
|
111
|
+
export const HELD_MS = 100 * 365 * 24 * 3600_000
|
|
108
112
|
export const READ_TTL_MS = 5 * 60_000
|
|
109
113
|
export const VOICE_FILE_BYTES = 25 * 2 ** 20
|
|
110
114
|
/** A voice file may be this many bytes per declared second (768 kbit/s, generous for any recorder) plus a little slack: ten seconds cannot claim 25 MB. */
|
|
@@ -149,6 +153,13 @@ async function readBody(req: Request, cap = BODY_CAP, optional = false): Promise
|
|
|
149
153
|
|
|
150
154
|
const iso = (ms: number) => new Date(ms).toISOString()
|
|
151
155
|
const lastStatus = (r: StoredReport) => r.timeline[r.timeline.length - 1]?.status ?? 'received'
|
|
156
|
+
/** Past asking the owner: the work has started or ended, and whoever holds the approval keeps it. */
|
|
157
|
+
const PAST_ASKING = ['being-made', ...END]
|
|
158
|
+
/** The maintainer named on the latest move that started or ended the work; none on a report from before 0.1.4. */
|
|
159
|
+
const holderOf = (r: StoredReport): string | undefined => {
|
|
160
|
+
for (let i = r.timeline.length - 1; i >= 0; i--) if (r.timeline[i].holder) return r.timeline[i].holder
|
|
161
|
+
return undefined
|
|
162
|
+
}
|
|
152
163
|
const str = (v: unknown, max: number): string | null => (typeof v === 'string' && v.length <= max ? v : null)
|
|
153
164
|
const fileNames = (r: StoredReport): string[] => [
|
|
154
165
|
...((r.media as { file: string }[] | undefined) ?? []).map((m) => m.file),
|
|
@@ -382,15 +393,32 @@ export function createReportHandlers(opts: ReportHandlerOptions) {
|
|
|
382
393
|
const by = await maintainerFor(req); if (!by) return signIn()
|
|
383
394
|
const out: StoredReport[] = []
|
|
384
395
|
// A page, oldest first. Reports from before this maintainer was added are skipped when `maintainersSince` says so.
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
await store.
|
|
390
|
-
|
|
396
|
+
// A report another maintainer already closed is never offered: it is settled for this maintainer here, unseen, so
|
|
397
|
+
// it neither raises a notice on this Mac nor sits at the head of every page. Settling one frees a slot, so the
|
|
398
|
+
// store is asked again (a few times at most) until the page fills or nothing more was settled.
|
|
399
|
+
for (let round = 0; round < 4 && out.length < limits.agentPage; round++) {
|
|
400
|
+
const batch = await store.listOpenFor(by, { limit: limits.agentPage * 3, since: since(by) })
|
|
401
|
+
let settled = 0
|
|
402
|
+
for (const r of batch) {
|
|
403
|
+
if (out.length >= limits.agentPage) break
|
|
404
|
+
if (END.includes(lastStatus(r)) && holderOf(r) !== by) {
|
|
405
|
+
await store.claim(r.id, by, LEASE_MS)
|
|
406
|
+
if ((await store.settle(r.id, by, reportRevision(r))) === 'settled') settled++
|
|
407
|
+
continue
|
|
408
|
+
}
|
|
409
|
+
// A report still waiting for its attachments is held back, so the Mac never copies half of one.
|
|
410
|
+
if (!complete(r) && now() - Date.parse(r.receivedAt) < INCOMPLETE_GRACE_MS) continue
|
|
411
|
+
await store.claim(r.id, by, LEASE_MS)
|
|
412
|
+
out.push(r)
|
|
413
|
+
}
|
|
414
|
+
if (!settled || batch.length < limits.agentPage * 3) break
|
|
391
415
|
}
|
|
392
416
|
// `revision` is what this Mac is being shown; it goes back with the settle so a report that grew in between is not settled.
|
|
393
|
-
|
|
417
|
+
// `heldBy` names another maintainer whose Mac is making the report, so this Mac files it as taken.
|
|
418
|
+
return json(200, { reports: out.map((r) => {
|
|
419
|
+
const holder = lastStatus(r) === 'being-made' ? holderOf(r) : undefined
|
|
420
|
+
return { ...r, revision: reportRevision(r), ...(holder && holder !== by ? { heldBy: holder } : {}) }
|
|
421
|
+
}) })
|
|
394
422
|
}
|
|
395
423
|
|
|
396
424
|
async function agentFile(req: Request, id: string, file: string): Promise<Response> {
|
|
@@ -422,12 +450,26 @@ export function createReportHandlers(opts: ReportHandlerOptions) {
|
|
|
422
450
|
return json(200, { id, status: moves ? 'with-freeda' : lastStatus(r) })
|
|
423
451
|
}
|
|
424
452
|
|
|
453
|
+
/** Takes (or renews) the approval for `by`. A closed report is never taken, by anyone: it answers who closed it. A
|
|
454
|
+
* report being made stays with the Mac that started it. Taken for a move that starts or ends the work, or on a
|
|
455
|
+
* report already being made, the lease is HELD_MS, so it does not lapse; before that, a week. Writes nothing when refused. */
|
|
456
|
+
async function takeApproval(r: StoredReport, by: string, to?: string): Promise<{ ok: true } | { closed: string; claimedBy?: string } | { claimedBy: string }> {
|
|
457
|
+
const from = lastStatus(r)
|
|
458
|
+
const holder = holderOf(r)
|
|
459
|
+
if (END.includes(from)) return { closed: from, ...(holder ? { claimedBy: holder } : {}) }
|
|
460
|
+
if (from === 'being-made' && holder && holder !== by) return { claimedBy: holder }
|
|
461
|
+
const lasting = from === 'being-made' || (to !== undefined && PAST_ASKING.includes(to))
|
|
462
|
+
return store.claimApproval(r.id, by, lasting ? HELD_MS : APPROVAL_LEASE_MS)
|
|
463
|
+
}
|
|
464
|
+
|
|
425
465
|
async function agentClaimApproval(req: Request, id: string): Promise<Response> {
|
|
426
466
|
const m = wrongMethod(req, 'POST'); if (m) return m
|
|
427
467
|
const by = await maintainerFor(req); if (!by) return signIn()
|
|
428
468
|
const body = await readBody(req, BODY_CAP, true); if ('res' in body) return body.res
|
|
429
|
-
|
|
430
|
-
|
|
469
|
+
const r = isReportId(id) ? await store.get(id) : null
|
|
470
|
+
if (!r) return notFound()
|
|
471
|
+
const got = await takeApproval(r, by)
|
|
472
|
+
if ('closed' in got) return json(409, { error: 'this suggestion is closed', closed: true, status: got.closed, ...(got.claimedBy ? { claimedBy: got.claimedBy } : {}) })
|
|
431
473
|
return 'ok' in got ? json(200, { ok: true, claimedBy: by }) : json(409, { claimedBy: got.claimedBy })
|
|
432
474
|
}
|
|
433
475
|
|
|
@@ -455,12 +497,19 @@ export function createReportHandlers(opts: ReportHandlerOptions) {
|
|
|
455
497
|
if (!mayMove(from, status)) return json(409, { error: `a report cannot go from ${from} to ${status}` })
|
|
456
498
|
// Once the owner is being asked, the Mac that holds the approval decides what happens next. Leaving
|
|
457
499
|
// waiting-on-owner, starting the work and ending it need the claim (taken here if nobody holds it).
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
500
|
+
const needsApproval = from === 'waiting-on-owner' || PAST_ASKING.includes(status)
|
|
501
|
+
if (needsApproval) {
|
|
502
|
+
const c = await takeApproval(r, by, status)
|
|
503
|
+
if (!('ok' in c)) return json(409, { error: 'another Mac holds the approval for this report', ...(c.claimedBy ? { claimedBy: c.claimedBy } : {}) })
|
|
504
|
+
}
|
|
505
|
+
// The maintainer is named on a move that starts or ends the work, so the hold is known from the report itself.
|
|
506
|
+
const holder = PAST_ASKING.includes(status) ? { holder: by } : {}
|
|
507
|
+
const moved = await store.setStatus(id, { status, by: FLOW[status as keyof typeof FLOW].actor, at: iso(now()), ...(step ? { step } : {}), ...(note ? { note } : {}), ...holder }, from)
|
|
508
|
+
if (!moved) {
|
|
509
|
+
// The lasting lease was taken for a move that did not happen: bring it back to a week.
|
|
510
|
+
if (needsApproval && from !== 'being-made' && PAST_ASKING.includes(status)) await store.claimApproval(id, by, APPROVAL_LEASE_MS)
|
|
511
|
+
return json(409, { error: 'the report changed while this was sent; read it again' })
|
|
461
512
|
}
|
|
462
|
-
const moved = await store.setStatus(id, { status, by: FLOW[status as keyof typeof FLOW].actor, at: iso(now()), ...(step ? { step } : {}), ...(note ? { note } : {}) }, from)
|
|
463
|
-
if (!moved) return json(409, { error: 'the report changed while this was sent; read it again' })
|
|
464
513
|
await tell({ type: 'status', reportId: id })
|
|
465
514
|
return json(200, { id, status })
|
|
466
515
|
}
|
package/src/server/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// "@supersuit/suggest-a-change/server": web-standard APIs only, no runtime dependency, nothing from react or the client.
|
|
2
|
-
export { createReportHandlers, reportRevision, VOICE_BYTES_PER_SECOND, LEASE_MS, BODY_CAP, APPROVAL_LEASE_MS, READ_TTL_MS, INCOMPLETE_GRACE_MS, VOICE_FILE_BYTES } from './handlers.ts'
|
|
2
|
+
export { createReportHandlers, reportRevision, VOICE_BYTES_PER_SECOND, LEASE_MS, BODY_CAP, APPROVAL_LEASE_MS, HELD_MS, READ_TTL_MS, INCOMPLETE_GRACE_MS, VOICE_FILE_BYTES } from './handlers.ts'
|
|
3
3
|
export type { HostedStore, StoredReport, TimelineEntry, FileState, FollowUp, TellEvent, ReportHandlerOptions } from './handlers.ts'
|
|
4
4
|
export { sniffType, sameType, canonicalType, ACCEPTED_TYPES } from './sniff.ts'
|
|
5
5
|
export { effectiveLimits } from './rate.ts'
|