@hyperframes/share 0.0.0-stage → 0.9.22
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/LICENSE +190 -0
- package/README.md +51 -2
- package/dist/appOwnedPaths.d.mts +6 -0
- package/dist/fromReview.d.mts +15 -0
- package/dist/grantRefusal.d.mts +8 -0
- package/dist/host.d.mts +29 -0
- package/dist/hosts.d.ts +39 -0
- package/dist/index.d.mts +21 -0
- package/dist/projectLock.d.mts +17 -0
- package/dist/projectSnapshot.d.mts +20 -0
- package/dist/reviewAudience.d.mts +3 -0
- package/dist/reviewClient.d.mts +122 -0
- package/dist/reviewExport.d.mts +172 -0
- package/dist/reviewFiles.d.mts +43 -0
- package/dist/reviewLinkStates.d.mts +18 -0
- package/dist/reviewOpenCalls.d.mts +16 -0
- package/dist/reviewShare.d.mts +106 -0
- package/dist/reviewSwitch.d.mts +53 -0
- package/dist/reviewUpload.d.mts +16 -0
- package/dist/reviewVersionSentences.d.mts +2 -0
- package/dist/signedInRequest.d.mts +56 -0
- package/dist/storage.d.mts +20 -0
- package/dist/types/access.d.ts +12 -0
- package/dist/types/review.d.ts +59 -0
- package/dist/types/reviewWire.d.ts +200 -0
- package/dist/types/share.d.ts +119 -0
- package/dist/types/shareOpen.d.ts +96 -0
- package/package.json +112 -3
- package/scripts/copy-types.mjs +3 -0
- package/src/appOwnedPaths.mjs +27 -0
- package/src/fromReview.mjs +27 -0
- package/src/grantRefusal.mjs +28 -0
- package/src/host.mjs +46 -0
- package/src/hosts.d.ts +39 -0
- package/src/index.mjs +82 -0
- package/src/projectLock.mjs +120 -0
- package/src/projectSnapshot.mjs +120 -0
- package/src/reviewAudience.mjs +24 -0
- package/src/reviewClient.mjs +260 -0
- package/src/reviewExport.mjs +827 -0
- package/src/reviewFiles.mjs +98 -0
- package/src/reviewLinkStates.mjs +47 -0
- package/src/reviewOpenCalls.mjs +40 -0
- package/src/reviewShare.mjs +645 -0
- package/src/reviewSwitch.mjs +430 -0
- package/src/reviewUpload.mjs +98 -0
- package/src/reviewVersionSentences.mjs +48 -0
- package/src/signedInRequest.mjs +125 -0
- package/src/storage.mjs +116 -0
- package/src/types/access.d.ts +12 -0
- package/src/types/review.d.ts +59 -0
- package/src/types/reviewWire.d.ts +200 -0
- package/src/types/share.d.ts +119 -0
- package/src/types/shareOpen.d.ts +96 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Files on a project's review versions after the upload: what the Share popover's files line says, and attaching the
|
|
3
|
+
// last sync to the version on the page when that version went up without files and the project has not changed since
|
|
4
|
+
// (the owner synced after rendering). A version only ever carries files that produce its video.
|
|
5
|
+
import { normalize } from "./host.mjs";
|
|
6
|
+
import { ReviewFailed } from "./reviewClient.mjs";
|
|
7
|
+
import { markFiles, versionFacts, versionOnPage } from "./reviewExport.mjs";
|
|
8
|
+
|
|
9
|
+
/** Files vs. the last sync: `off` (share_files or sync off), `elsewhere`
|
|
10
|
+
* (syncs to `workspace`, not ours), `differs` (no sync, syncing, writing, changed), `matches` (a version can carry it).
|
|
11
|
+
* @typedef {{ at: "off" } | { at: "differs" } | { at: "elsewhere", workspace: string }
|
|
12
|
+
* | { at: "matches", snapshot: import("./types/reviewWire.js").SnapshotRef }} SyncedFiles */
|
|
13
|
+
|
|
14
|
+
/** @typedef {import("./types/share.js").ReviewFiles} FilesAnswer */
|
|
15
|
+
|
|
16
|
+
/** The files line from what is known: the version on the page carrying files says so first; a project synced to
|
|
17
|
+
* another workspace says which, since neither a sync nor a render here fixes it; a project moved past its render is
|
|
18
|
+
* `changed`; a render not on the page yet is `included` when the version it would add carries files.
|
|
19
|
+
* @param {Awaited<ReturnType<typeof versionFacts>>} facts @param {SyncedFiles} found @returns {FilesAnswer} */
|
|
20
|
+
export function filesStateOf({ last, source }, found) {
|
|
21
|
+
if (found.at === "off") return { at: "off" };
|
|
22
|
+
const onPage = last?.current ? last : null;
|
|
23
|
+
if (onPage?.files && source !== "stale") return { at: "included" };
|
|
24
|
+
if (found.at === "elsewhere" && (source === "ready" || source === "stale"))
|
|
25
|
+
return { at: "other_workspace", workspace: found.workspace };
|
|
26
|
+
if (source === "stale") return { at: "changed" };
|
|
27
|
+
if (source !== "ready") return { at: onPage?.files ? "included" : "off" };
|
|
28
|
+
if (onPage) return { at: "not_synced" };
|
|
29
|
+
return { at: found.at === "matches" ? "included" : "not_synced" };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** @param {string} versionId @param {import("./types/reviewWire.js").SnapshotRef} snapshot */
|
|
33
|
+
const pairOf = (versionId, snapshot) =>
|
|
34
|
+
[versionId, snapshot.space_id, snapshot.composition_id, snapshot.snapshot_id].join("\0");
|
|
35
|
+
|
|
36
|
+
/** `match` answers the files line; `forRender` is the only way to files for a version, held to its render (the same
|
|
37
|
+
* one an export's `filesForExport` is). @param {{ client: Pick<import("./reviewClient.mjs").ReviewClient,
|
|
38
|
+
* "attachSnapshot">, match: (projectPath: string) => Promise<SyncedFiles>,
|
|
39
|
+
* forRender: import("./reviewExport.mjs").FilesForRender }} deps */
|
|
40
|
+
export function reviewFiles({ client, match, forRender }) {
|
|
41
|
+
/** One attach per project at a time; a second asks and joins the first. @type {Map<string, Promise<void>>} */
|
|
42
|
+
const attaching = new Map();
|
|
43
|
+
/** Version and snapshot pairs the server refused as `snapshot_mismatch`: asking again gets the same answer, so they
|
|
44
|
+
* are never asked again while the app runs, and Sync can't help them. @type {Set<string>} */
|
|
45
|
+
const mismatched = new Set();
|
|
46
|
+
/** @param {string} dir */
|
|
47
|
+
const attachOnce = async (dir) => {
|
|
48
|
+
// The version, and so the render its files must produce, before any snapshot is looked at.
|
|
49
|
+
const last = await versionOnPage(dir);
|
|
50
|
+
if (!last || last.files) return;
|
|
51
|
+
const snapshot = await forRender(dir, last.fingerprint);
|
|
52
|
+
if (!snapshot) return;
|
|
53
|
+
const pair = pairOf(last.versionId, snapshot);
|
|
54
|
+
if (mismatched.has(pair)) return;
|
|
55
|
+
try {
|
|
56
|
+
await client.attachSnapshot(last.projectId, last.versionId, snapshot);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (snapshotRefusal(error, "snapshot_mismatch")) mismatched.add(pair);
|
|
59
|
+
if (!snapshotRefusal(error, "snapshot_already_attached")) throw error;
|
|
60
|
+
}
|
|
61
|
+
await markFiles(dir, last.versionId, last.projectId);
|
|
62
|
+
};
|
|
63
|
+
/** @param {string} dir @returns {Promise<void>} */
|
|
64
|
+
const attach = (dir) => {
|
|
65
|
+
const key = normalize(dir);
|
|
66
|
+
const running = attaching.get(key);
|
|
67
|
+
if (running) return running;
|
|
68
|
+
const run = attachOnce(dir).finally(() => attaching.delete(key));
|
|
69
|
+
attaching.set(key, run);
|
|
70
|
+
return run;
|
|
71
|
+
};
|
|
72
|
+
return {
|
|
73
|
+
/** Attaches the last sync to the version on the page when it can; anything else leaves it as it is. */
|
|
74
|
+
attach,
|
|
75
|
+
/** The files line, once any attach a sync made possible has gone in. @param {string} dir
|
|
76
|
+
* @returns {Promise<FilesAnswer>} */
|
|
77
|
+
state: async (dir) => {
|
|
78
|
+
await attach(dir).catch((error) => {
|
|
79
|
+
// expected: the line below reads what the server holds, so a failed attach shows as no files, with Sync now.
|
|
80
|
+
console.error("[review] files not attached:", error);
|
|
81
|
+
});
|
|
82
|
+
const [facts, found] = await Promise.all([versionFacts(dir), match(dir)]);
|
|
83
|
+
const answer = filesStateOf(facts, found);
|
|
84
|
+
// `not_synced` offers Sync now, which can't attach a snapshot the server refused for this version.
|
|
85
|
+
const refused =
|
|
86
|
+
answer.at === "not_synced" &&
|
|
87
|
+
found.at === "matches" &&
|
|
88
|
+
facts.last !== null &&
|
|
89
|
+
mismatched.has(pairOf(facts.last.versionId, found.snapshot));
|
|
90
|
+
return refused ? { at: "off" } : answer;
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** @typedef {ReturnType<typeof reviewFiles>} ReviewFiles */
|
|
96
|
+
|
|
97
|
+
/** @param {unknown} error @param {string} reason */
|
|
98
|
+
const snapshotRefusal = (error, reason) => error instanceof ReviewFailed && error.reason === reason;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Where each share link in Settings' list stands: on, turned off, paused or taken down, and who can open it.
|
|
3
|
+
|
|
4
|
+
/** @typedef {"on" | "off" | "paused" | "taken-down"} LinkState
|
|
5
|
+
* @typedef {{ state: LinkState, access: import("./types/share.js").AccessMode,
|
|
6
|
+
* pausedFor?: "report" | "plan" }} LinkStateRead */
|
|
7
|
+
|
|
8
|
+
/** One page of the list at most, so a team's list never fans out into hundreds of reads. */
|
|
9
|
+
const MOST = 50;
|
|
10
|
+
const AT_ONCE = 4;
|
|
11
|
+
|
|
12
|
+
/** @param {import("./types/reviewWire.js").ShareDTO} share @returns {LinkStateRead} */
|
|
13
|
+
export function linkStateOf(share) {
|
|
14
|
+
const access = share.access_mode;
|
|
15
|
+
if (share.revoked) return { state: "off", access };
|
|
16
|
+
if (share.moderation_hold)
|
|
17
|
+
return share.moderation_hold_kind === "takedown"
|
|
18
|
+
? { state: "taken-down", access }
|
|
19
|
+
: { state: "paused", access, pausedFor: "report" };
|
|
20
|
+
if (share.sharing_state?.state === "subscription_required")
|
|
21
|
+
return { state: "paused", access, pausedFor: "plan" };
|
|
22
|
+
return { state: "on", access };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Each project's link, read a few at a time; a read that fails leaves that project out, so its row shows no state.
|
|
26
|
+
* @param {{ client: { share: (id: string) => Promise<import("./types/reviewWire.js").ShareDTO> },
|
|
27
|
+
* projectIds: string[] }} request
|
|
28
|
+
* @returns {Promise<Record<string, LinkStateRead>>} */
|
|
29
|
+
export async function reviewLinkStates({ client, projectIds }) {
|
|
30
|
+
const ids = [...new Set(projectIds)].slice(0, MOST);
|
|
31
|
+
/** @type {Record<string, LinkStateRead>} */
|
|
32
|
+
const states = {};
|
|
33
|
+
let next = 0;
|
|
34
|
+
const worker = async () => {
|
|
35
|
+
while (next < ids.length) {
|
|
36
|
+
const id = /** @type {string} */ (ids[next++]);
|
|
37
|
+
try {
|
|
38
|
+
states[id] = linkStateOf(await client.share(id));
|
|
39
|
+
} catch (error) {
|
|
40
|
+
// expected: one unreadable link leaves its row without a state; the rest of the list still shows theirs.
|
|
41
|
+
console.warn("[review] link state not read:", /** @type {any} */ (error)?.reason ?? error);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
await Promise.all(Array.from({ length: Math.min(AT_ONCE, ids.length) }, worker));
|
|
46
|
+
return states;
|
|
47
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Open in Studio's calls (docs/sync-backed-sharing/contracts.md C1-C3): what a share opens to, the version's files for
|
|
3
|
+
// a copy, and the copy's log.
|
|
4
|
+
|
|
5
|
+
const REVIEWS = "/v1/hyperframes/reviews";
|
|
6
|
+
|
|
7
|
+
/** @param {(what: string, method: string, route: string, body?: unknown) => Promise<any>} tokenCall */
|
|
8
|
+
export function shareOpenCalls(tokenCall) {
|
|
9
|
+
const share = (/** @type {string} */ token) => `${REVIEWS}/${encodeURIComponent(token)}`;
|
|
10
|
+
const version = (/** @type {string} */ token, /** @type {string} */ id) =>
|
|
11
|
+
`${share(token)}/versions/${encodeURIComponent(id)}`;
|
|
12
|
+
return {
|
|
13
|
+
/** C1, for the requested version or the latest. @param {string} token @param {string | null} versionId
|
|
14
|
+
* @returns {Promise<import("./types/shareOpen.js").OpenSummaryDTO>} */
|
|
15
|
+
openSummary: (token, versionId) =>
|
|
16
|
+
tokenCall(
|
|
17
|
+
"GET reviews/…/open",
|
|
18
|
+
"GET",
|
|
19
|
+
`${share(token)}/open${versionId ? `?${new URLSearchParams({ version_id: versionId })}` : ""}`,
|
|
20
|
+
),
|
|
21
|
+
/** C2: the snapshot's manifest and the first signed URLs (at most 500 files).
|
|
22
|
+
* @param {string} token @param {string} versionId @returns {Promise<import("./types/shareOpen.js").VersionFilesDTO>} */
|
|
23
|
+
versionFiles: (token, versionId) =>
|
|
24
|
+
tokenCall("GET reviews/…/files", "GET", `${version(token, versionId)}/files`),
|
|
25
|
+
/** C2's refresh: URLs for `sha256s` (at most 500) of the same snapshot, by the token the last answer carried.
|
|
26
|
+
* @param {string} token @param {string} versionId @param {string} continuation @param {string[]} sha256s
|
|
27
|
+
* @returns {Promise<import("./types/shareOpen.js").FileUrlsDTO>} */
|
|
28
|
+
fileUrls: (token, versionId, continuation, sha256s) =>
|
|
29
|
+
tokenCall("POST reviews/…/files", "POST", `${version(token, versionId)}/files`, {
|
|
30
|
+
continuation_token: continuation,
|
|
31
|
+
sha256s,
|
|
32
|
+
}),
|
|
33
|
+
/** C3: a copy finished; the same handoff again is the same copy. The server answers 204 with no body.
|
|
34
|
+
* @param {string} token @param {string} versionId @param {string | null} handoffId @returns {Promise<null>} */
|
|
35
|
+
logCopy: (token, versionId, handoffId) =>
|
|
36
|
+
tokenCall("POST reviews/…/copies", "POST", `${version(token, versionId)}/copies`, {
|
|
37
|
+
handoff_id: handoffId,
|
|
38
|
+
}),
|
|
39
|
+
};
|
|
40
|
+
}
|