@hyperframes/share 0.9.27 → 0.9.28-alpha.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/README.md +58 -1
- package/dist/index.d.mts +1 -0
- package/dist/projectLock.d.mts +2 -2
- package/dist/projectSnapshot.d.mts +3 -1
- package/dist/reviewExport.d.mts +11 -1
- package/dist/reviewUpload.d.mts +2 -0
- package/dist/shareSources.d.mts +24 -0
- package/dist/sources/adapterTypes.d.ts +39 -0
- package/dist/sources/api.d.mts +29 -0
- package/dist/sources/capture.d.mts +24 -0
- package/dist/sources/contract.d.mts +2 -0
- package/dist/sources/errors.d.mts +24 -0
- package/dist/sources/limits.d.mts +12 -0
- package/dist/sources/manifest.d.mts +23 -0
- package/dist/sources/pathPolicy.d.mts +25 -0
- package/dist/sources/projectState.d.mts +9 -0
- package/dist/sources/store.d.mts +3 -0
- package/dist/sources/transfer.d.mts +45 -0
- package/dist/sources/types.d.ts +353 -0
- package/dist/sources/unicode.d.mts +15 -0
- package/dist/sources/upload.d.mts +9 -0
- package/dist/sources/uploadFiles.d.mts +31 -0
- package/dist/sources/wire.d.mts +21 -0
- package/package.json +1 -1
- package/scripts/copy-types.mjs +3 -0
- package/src/index.mjs +6 -0
- package/src/projectLock.mjs +9 -2
- package/src/projectSnapshot.mjs +11 -4
- package/src/reviewExport.mjs +145 -37
- package/src/reviewUpload.mjs +18 -16
- package/src/shareSources.mjs +236 -0
- package/src/sources/adapterTypes.d.ts +39 -0
- package/src/sources/api.mjs +114 -0
- package/src/sources/capture.mjs +262 -0
- package/src/sources/contract/LICENSE-Unicode.txt +39 -0
- package/src/sources/contract/path-policy.json +311 -0
- package/src/sources/contract/unicode-15.1.0.json +6179 -0
- package/src/sources/contract.mjs +13 -0
- package/src/sources/errors.mjs +77 -0
- package/src/sources/limits.mjs +48 -0
- package/src/sources/manifest.mjs +154 -0
- package/src/sources/pathPolicy.mjs +197 -0
- package/src/sources/projectState.mjs +59 -0
- package/src/sources/store.mjs +82 -0
- package/src/sources/transfer.mjs +196 -0
- package/src/sources/types.d.ts +353 -0
- package/src/sources/unicode.mjs +163 -0
- package/src/sources/upload.mjs +201 -0
- package/src/sources/uploadFiles.mjs +263 -0
- package/src/sources/wire.mjs +205 -0
- package/LICENSE +0 -190
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Use the bundled Unicode 15.1 tables so path comparison is independent of the runtime Unicode version.
|
|
3
|
+
import { readContract } from "./contract.mjs";
|
|
4
|
+
|
|
5
|
+
export const UNICODE_VERSION = "15.1.0";
|
|
6
|
+
|
|
7
|
+
/** @typedef {{ unicode_version: string, casefold: Record<string, string>, combining: Record<string, number>,
|
|
8
|
+
* decomposition: Record<string, number[]>, composition: Record<string, number>,
|
|
9
|
+
* assigned_ranges: [number, number][] }} UnicodeTableFile */
|
|
10
|
+
|
|
11
|
+
const HANGUL_BASE = 0xac00;
|
|
12
|
+
const HANGUL_COUNT = 11172;
|
|
13
|
+
const L_BASE = 0x1100;
|
|
14
|
+
const V_BASE = 0x1161;
|
|
15
|
+
const T_BASE = 0x11a7;
|
|
16
|
+
const L_COUNT = 19;
|
|
17
|
+
const V_COUNT = 21;
|
|
18
|
+
const T_COUNT = 28;
|
|
19
|
+
const N_COUNT = V_COUNT * T_COUNT;
|
|
20
|
+
|
|
21
|
+
/** The table re-keyed by code point, decompositions fully expanded. @param {UnicodeTableFile} raw */
|
|
22
|
+
function tablesOf(raw) {
|
|
23
|
+
if (raw.unicode_version !== UNICODE_VERSION)
|
|
24
|
+
throw new Error(`unicode table is ${raw.unicode_version}, expected ${UNICODE_VERSION}`);
|
|
25
|
+
const hex = (/** @type {string} */ code) => Number.parseInt(code, 16);
|
|
26
|
+
const casefold = new Map(Object.entries(raw.casefold).map(([code, to]) => [hex(code), to]));
|
|
27
|
+
const combining = new Map(Object.entries(raw.combining).map(([code, cc]) => [hex(code), cc]));
|
|
28
|
+
const direct = new Map(Object.entries(raw.decomposition).map(([code, seq]) => [hex(code), seq]));
|
|
29
|
+
/** @param {number} code @returns {number[]} */
|
|
30
|
+
const expand = (code) => direct.get(code)?.flatMap(expand) ?? [code];
|
|
31
|
+
const decomposition = new Map([...direct.keys()].map((code) => [code, expand(code)]));
|
|
32
|
+
const composition = new Map(
|
|
33
|
+
Object.entries(raw.composition).map(([pair, composite]) => {
|
|
34
|
+
const [first = "", second = ""] = pair.split(",");
|
|
35
|
+
return [`${hex(first)},${hex(second)}`, composite];
|
|
36
|
+
}),
|
|
37
|
+
);
|
|
38
|
+
const starts = raw.assigned_ranges.map(([start]) => start);
|
|
39
|
+
const ends = raw.assigned_ranges.map(([, end]) => end);
|
|
40
|
+
return { casefold, combining, decomposition, composition, starts, ends };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** @type {ReturnType<typeof tablesOf> | null} */
|
|
44
|
+
let loaded = null;
|
|
45
|
+
/** The table, read and indexed when a path is first compared: an app that never syncs never reads it. */
|
|
46
|
+
const tables = () => (loaded ??= tablesOf(readContract("unicode-15.1.0.json")));
|
|
47
|
+
|
|
48
|
+
/** @param {number} code */
|
|
49
|
+
const classOf = (code) => tables().combining.get(code) ?? 0;
|
|
50
|
+
|
|
51
|
+
/** @param {number} code @returns {readonly number[]} */
|
|
52
|
+
function decompose(code) {
|
|
53
|
+
const syllable = code - HANGUL_BASE;
|
|
54
|
+
if (syllable >= 0 && syllable < HANGUL_COUNT) {
|
|
55
|
+
const trailing = syllable % T_COUNT;
|
|
56
|
+
const leading = [
|
|
57
|
+
L_BASE + Math.floor(syllable / N_COUNT),
|
|
58
|
+
V_BASE + Math.floor((syllable % N_COUNT) / T_COUNT),
|
|
59
|
+
];
|
|
60
|
+
return trailing ? [...leading, T_BASE + trailing] : leading;
|
|
61
|
+
}
|
|
62
|
+
return tables().decomposition.get(code) ?? [code];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** @param {number} first @param {number} second @returns {number | undefined} */
|
|
66
|
+
function composePair(first, second) {
|
|
67
|
+
if (first >= L_BASE && first < L_BASE + L_COUNT && second >= V_BASE && second < V_BASE + V_COUNT)
|
|
68
|
+
return HANGUL_BASE + ((first - L_BASE) * V_COUNT + second - V_BASE) * T_COUNT;
|
|
69
|
+
return composeSyllable(first, second) ?? tables().composition.get(`${first},${second}`);
|
|
70
|
+
}
|
|
71
|
+
/** @param {number} first @param {number} second */
|
|
72
|
+
function composeSyllable(first, second) {
|
|
73
|
+
if (
|
|
74
|
+
first >= HANGUL_BASE &&
|
|
75
|
+
first < HANGUL_BASE + HANGUL_COUNT &&
|
|
76
|
+
(first - HANGUL_BASE) % T_COUNT === 0 &&
|
|
77
|
+
second > T_BASE &&
|
|
78
|
+
second < T_BASE + T_COUNT
|
|
79
|
+
)
|
|
80
|
+
return first + second - T_BASE;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Canonical decomposition then canonical ordering of combining marks. @param {string} value */
|
|
84
|
+
function orderedCodes(value) {
|
|
85
|
+
/** @type {number[]} */
|
|
86
|
+
const ordered = [];
|
|
87
|
+
for (const char of value) {
|
|
88
|
+
for (const code of decompose(char.codePointAt(0) ?? 0)) {
|
|
89
|
+
const current = classOf(code);
|
|
90
|
+
let position = ordered.length;
|
|
91
|
+
while (current && position && classOf(ordered[position - 1] ?? 0) > current) position--;
|
|
92
|
+
ordered.splice(position, 0, code);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return ordered;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Canonical composition under the pinned Unicode table. @param {number[]} ordered */
|
|
99
|
+
function composed(ordered) {
|
|
100
|
+
/** @type {number[]} */
|
|
101
|
+
const result = [];
|
|
102
|
+
let starter = 0;
|
|
103
|
+
let lastClass = 0;
|
|
104
|
+
for (const code of ordered) {
|
|
105
|
+
const current = classOf(code);
|
|
106
|
+
const head = result[starter];
|
|
107
|
+
const composite = head === undefined ? undefined : composePair(head, code);
|
|
108
|
+
if (composite !== undefined && (lastClass < current || lastClass === 0)) {
|
|
109
|
+
result[starter] = composite;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (current === 0) starter = result.length;
|
|
113
|
+
result.push(code);
|
|
114
|
+
lastClass = current;
|
|
115
|
+
}
|
|
116
|
+
return String.fromCodePoint(...result);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const ASCII = /^\p{ASCII}*$/u;
|
|
120
|
+
|
|
121
|
+
/** NFC (UAX #15) under the pinned table. @param {string} value */
|
|
122
|
+
export const nfc = (value) => (ASCII.test(value) ? value : composed(orderedCodes(value)));
|
|
123
|
+
|
|
124
|
+
/** Default full case folding (status C and F), so `ß` folds to `ss`. @param {string} value */
|
|
125
|
+
function fullCasefold(value) {
|
|
126
|
+
if (ASCII.test(value)) return value.toLowerCase();
|
|
127
|
+
let folded = "";
|
|
128
|
+
for (const char of value) folded += tables().casefold.get(char.codePointAt(0) ?? 0) ?? char;
|
|
129
|
+
return folded;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Whether every code point is assigned in Unicode 15.1. @param {string} value */
|
|
133
|
+
export function allAssigned(value) {
|
|
134
|
+
const { starts, ends } = tables();
|
|
135
|
+
for (const char of value) {
|
|
136
|
+
const code = char.codePointAt(0) ?? 0;
|
|
137
|
+
let low = 0;
|
|
138
|
+
let high = starts.length;
|
|
139
|
+
while (low < high) {
|
|
140
|
+
const middle = (low + high) >>> 1;
|
|
141
|
+
if ((starts[middle] ?? Infinity) <= code) low = middle + 1;
|
|
142
|
+
else high = middle;
|
|
143
|
+
}
|
|
144
|
+
if (low === 0 || code > (ends[low - 1] ?? -1)) return false;
|
|
145
|
+
}
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Directory names repeat across a manifest's paths, and each segment is keyed twice (policy, then collisions). */
|
|
150
|
+
const KEYS_KEPT = 16384;
|
|
151
|
+
/** @type {Map<string, string>} */
|
|
152
|
+
const keys = new Map();
|
|
153
|
+
|
|
154
|
+
/** The spelling two names share on a case-insensitive or normalizing filesystem: NFC(casefold(NFC(value))).
|
|
155
|
+
* @param {string} value */
|
|
156
|
+
export function comparisonKey(value) {
|
|
157
|
+
const known = keys.get(value);
|
|
158
|
+
if (known !== undefined) return known;
|
|
159
|
+
const key = nfc(fullCasefold(nfc(value)));
|
|
160
|
+
if (keys.size >= KEYS_KEPT) keys.clear();
|
|
161
|
+
keys.set(value, key);
|
|
162
|
+
return key;
|
|
163
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { NotWritable, uploadNeeded } from "./uploadFiles.mjs";
|
|
5
|
+
import { parseStoredManifest } from "./manifest.mjs";
|
|
6
|
+
import { SyncApiError } from "./errors.mjs";
|
|
7
|
+
|
|
8
|
+
/** @typedef {import("./adapterTypes.js").SourceRun} SourceRun
|
|
9
|
+
* @typedef {Pick<SourceRun, "binding" | "api" | "store">} SourceUpload
|
|
10
|
+
* @typedef {import("./types.js").UploadReceipt} UploadReceipt */
|
|
11
|
+
/** @param {SourceUpload & Pick<SourceRun, "manifest">} run */
|
|
12
|
+
async function begin(run) {
|
|
13
|
+
const { binding, manifest, api, store } = run;
|
|
14
|
+
if (!binding.inflight) {
|
|
15
|
+
binding.inflight = {
|
|
16
|
+
idempotencyKey: randomUUID(),
|
|
17
|
+
baseSnapshotId: binding.base?.snapshotId ?? null,
|
|
18
|
+
resolvesConflictSnapshotIds: [],
|
|
19
|
+
summary: null,
|
|
20
|
+
manifest: manifest.document,
|
|
21
|
+
manifestSha256: manifest.sha256,
|
|
22
|
+
uploadId: null,
|
|
23
|
+
snapshotId: null,
|
|
24
|
+
uploadDeadline: null,
|
|
25
|
+
verificationDeadline: null,
|
|
26
|
+
begunAt: Date.now(),
|
|
27
|
+
sent: [],
|
|
28
|
+
multipart: {},
|
|
29
|
+
};
|
|
30
|
+
await store.write(binding);
|
|
31
|
+
}
|
|
32
|
+
const inflight = binding.inflight;
|
|
33
|
+
const receipt = await api.begin(binding.ownerSpaceId, binding.compositionId, {
|
|
34
|
+
...inflight,
|
|
35
|
+
manifest,
|
|
36
|
+
});
|
|
37
|
+
assertReceipt(binding, receipt);
|
|
38
|
+
Object.assign(inflight, {
|
|
39
|
+
uploadId: receipt.uploadId,
|
|
40
|
+
snapshotId: receipt.snapshotId,
|
|
41
|
+
uploadDeadline: receipt.uploadDeadline,
|
|
42
|
+
verificationDeadline: receipt.verificationDeadline,
|
|
43
|
+
});
|
|
44
|
+
await store.write(binding);
|
|
45
|
+
return receipt;
|
|
46
|
+
}
|
|
47
|
+
/** @param {SourceRun} run @param {import("./types.js").BeginResult} receipt */
|
|
48
|
+
async function sendFiles(run, receipt) {
|
|
49
|
+
const { binding, manifest, store } = run;
|
|
50
|
+
if (!binding.inflight) throw new Error("Missing source upload intent.");
|
|
51
|
+
const local = new Map(
|
|
52
|
+
manifest.document.files.map((file) => [
|
|
53
|
+
file.sha256,
|
|
54
|
+
{ path: file.path, full: join(binding.projectPath, file.path) },
|
|
55
|
+
]),
|
|
56
|
+
);
|
|
57
|
+
const known = new Map(manifest.document.files.map((file) => [file.sha256, file.size]));
|
|
58
|
+
for (const blob of receipt.needed)
|
|
59
|
+
if (known.get(blob.sha256) !== blob.size)
|
|
60
|
+
throw new Error("Upload requested an unknown source file.");
|
|
61
|
+
let persisted = Promise.resolve();
|
|
62
|
+
await uploadNeeded({
|
|
63
|
+
api: run.api,
|
|
64
|
+
space: binding.ownerSpaceId,
|
|
65
|
+
composition: binding.compositionId,
|
|
66
|
+
uploadId: receipt.uploadId,
|
|
67
|
+
inflight: binding.inflight,
|
|
68
|
+
local,
|
|
69
|
+
needed: receipt.needed,
|
|
70
|
+
fetchImpl: run.fetchImpl,
|
|
71
|
+
wait: run.wait,
|
|
72
|
+
signal: run.signal,
|
|
73
|
+
now: Date.now,
|
|
74
|
+
persist: () => {
|
|
75
|
+
persisted = persisted.then(() => store.write(binding));
|
|
76
|
+
},
|
|
77
|
+
onBytes: run.onProgress,
|
|
78
|
+
onFile: () => {},
|
|
79
|
+
}).finally(() => persisted);
|
|
80
|
+
return run.api.submit(binding.ownerSpaceId, binding.compositionId, receipt.uploadId);
|
|
81
|
+
}
|
|
82
|
+
/** @param {SourceUpload["binding"]} binding @param {UploadReceipt} receipt */
|
|
83
|
+
function assertReceipt(binding, receipt) {
|
|
84
|
+
const saved = binding.inflight;
|
|
85
|
+
if (!saved || receipt.manifestSha256 !== saved.manifestSha256)
|
|
86
|
+
throw new Error("Source upload receipt does not match the captured project.");
|
|
87
|
+
if (receipt.compositionId !== binding.compositionId)
|
|
88
|
+
throw new Error("Source upload receipt does not match the composition.");
|
|
89
|
+
if (saved.uploadId !== null && receipt.uploadId !== saved.uploadId)
|
|
90
|
+
throw new Error("Source upload receipt identity changed.");
|
|
91
|
+
if (saved.snapshotId !== null && receipt.snapshotId !== saved.snapshotId)
|
|
92
|
+
throw new Error("Source snapshot identity changed.");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** @param {SourceUpload} job @param {UploadReceipt} receipt */
|
|
96
|
+
async function storePublished({ binding, store }, receipt) {
|
|
97
|
+
const saved = binding.inflight;
|
|
98
|
+
const result = receipt.result;
|
|
99
|
+
if (!saved || !result || result.snapshotId !== receipt.snapshotId)
|
|
100
|
+
throw new Error("Source snapshot identity changed.");
|
|
101
|
+
binding.base = {
|
|
102
|
+
snapshotId: result.snapshotId,
|
|
103
|
+
manifest: saved.manifest,
|
|
104
|
+
manifestSha256: saved.manifestSha256,
|
|
105
|
+
};
|
|
106
|
+
await clearIntent({ binding, store });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** @param {Pick<SourceUpload, "binding" | "store">} job */
|
|
110
|
+
async function clearIntent({ binding, store }) {
|
|
111
|
+
binding.inflight = null;
|
|
112
|
+
await store.write(binding);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** @param {SourceUpload} job @param {UploadReceipt} receipt */
|
|
116
|
+
async function cancelPending({ binding, api }, receipt) {
|
|
117
|
+
const { ownerSpaceId, compositionId } = binding;
|
|
118
|
+
try {
|
|
119
|
+
return await api.cancel(ownerSpaceId, compositionId, receipt.uploadId);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
if (!(error instanceof SyncApiError) || error.kind !== "upload-not-pending") throw error;
|
|
122
|
+
return api.status(ownerSpaceId, compositionId, receipt.uploadId);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Resolve an older capture before starting an upload for the current files.
|
|
127
|
+
* @param {SourceUpload} job @returns {Promise<import("./adapterTypes.js").SourceResult | null>} */
|
|
128
|
+
export async function recoverSourceUpload(job) {
|
|
129
|
+
const { binding, api } = job;
|
|
130
|
+
const saved = binding.inflight;
|
|
131
|
+
if (!saved) return null;
|
|
132
|
+
const manifest = parseStoredManifest(saved.manifest);
|
|
133
|
+
if (manifest.sha256 !== saved.manifestSha256)
|
|
134
|
+
throw new Error("Saved source upload manifest does not match its checksum.");
|
|
135
|
+
let receipt = saved.uploadId
|
|
136
|
+
? await api.status(binding.ownerSpaceId, binding.compositionId, saved.uploadId)
|
|
137
|
+
: await begin({ ...job, manifest });
|
|
138
|
+
assertReceipt(binding, receipt);
|
|
139
|
+
if (receipt.status === "pending") receipt = await cancelPending(job, receipt);
|
|
140
|
+
assertReceipt(binding, receipt);
|
|
141
|
+
return recoverReceipt(job, receipt);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** @param {SourceUpload} job @param {UploadReceipt} receipt */
|
|
145
|
+
async function recoverReceipt(job, receipt) {
|
|
146
|
+
if (receipt.status === "completed") {
|
|
147
|
+
if (receipt.result?.publicationOutcome !== "published")
|
|
148
|
+
return { at: /** @type {const} */ ("unavailable"), reason: "source_conflict" };
|
|
149
|
+
await storePublished(job, receipt);
|
|
150
|
+
} else if (terminalFailure(receipt)) await clearIntent(job);
|
|
151
|
+
else return { at: /** @type {const} */ ("unavailable"), reason: "source_verification_pending" };
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** @param {UploadReceipt} receipt */
|
|
156
|
+
const terminalFailure = (receipt) => receipt.status === "failed" || receipt.status === "expired";
|
|
157
|
+
|
|
158
|
+
/** Only published verification results can become share source; conflict_saved is preserved for explicit recovery.
|
|
159
|
+
* @param {SourceRun} run @returns {Promise<import("./adapterTypes.js").SourceResult>} */
|
|
160
|
+
export async function uploadSource(run) {
|
|
161
|
+
const receipt = await begin(run);
|
|
162
|
+
let status;
|
|
163
|
+
try {
|
|
164
|
+
status = receipt.status === "pending" ? await sendFiles(run, receipt) : receipt;
|
|
165
|
+
} catch (error) {
|
|
166
|
+
if (!(error instanceof NotWritable)) throw error;
|
|
167
|
+
return (
|
|
168
|
+
(await recoverSourceUpload(run)) ?? {
|
|
169
|
+
at: "unavailable",
|
|
170
|
+
reason: "source_upload_not_writable",
|
|
171
|
+
}
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
const deadline = Date.now() + run.timeoutMs;
|
|
175
|
+
while (status.status === "verifying") {
|
|
176
|
+
run.signal.throwIfAborted();
|
|
177
|
+
if (Date.now() >= deadline) return { at: "unavailable", reason: "source_verification_pending" };
|
|
178
|
+
await run.wait(1000, run.signal);
|
|
179
|
+
status = await run.api.status(
|
|
180
|
+
run.binding.ownerSpaceId,
|
|
181
|
+
run.binding.compositionId,
|
|
182
|
+
receipt.uploadId,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
assertReceipt(run.binding, status);
|
|
186
|
+
if (status.status !== "completed" || !status.result) {
|
|
187
|
+
if (terminalFailure(status)) await clearIntent(run);
|
|
188
|
+
return { at: "unavailable", reason: status.errorCode ?? `source_upload_${status.status}` };
|
|
189
|
+
}
|
|
190
|
+
if (status.result.publicationOutcome !== "published")
|
|
191
|
+
return { at: "unavailable", reason: "source_conflict" };
|
|
192
|
+
await storePublished(run, status);
|
|
193
|
+
return {
|
|
194
|
+
at: "ready",
|
|
195
|
+
snapshot: {
|
|
196
|
+
space_id: run.binding.ownerSpaceId,
|
|
197
|
+
composition_id: run.binding.compositionId,
|
|
198
|
+
snapshot_id: status.snapshotId,
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// The files a Begin said are needed, written to the upload's staging through signed URLs: one PUT for a file up to
|
|
3
|
+
// 64 MiB, parts of 64 MiB above that through the file's one pinned multipart upload. URLs live at most 15 minutes, so
|
|
4
|
+
// one about to end is asked for again; a send resumed after a quit skips what the in-flight record says was written.
|
|
5
|
+
import { SyncApiError } from "./errors.mjs";
|
|
6
|
+
import {
|
|
7
|
+
TransferError,
|
|
8
|
+
backoffMs,
|
|
9
|
+
eachLimit,
|
|
10
|
+
isAbort,
|
|
11
|
+
partDigests,
|
|
12
|
+
putRange,
|
|
13
|
+
} from "./transfer.mjs";
|
|
14
|
+
import { lstat } from "node:fs/promises";
|
|
15
|
+
|
|
16
|
+
/** @typedef {import("./types.js").FileGrant} FileGrant @typedef {import("./types.js").GrantRequest} GrantRequest
|
|
17
|
+
* @typedef {import("./types.js").InflightUpload} InflightUpload */
|
|
18
|
+
|
|
19
|
+
/** The server's single-PUT ceiling and part size. */
|
|
20
|
+
const PART_BYTES = 64 * 1024 * 1024;
|
|
21
|
+
const GRANT_FILES = 100;
|
|
22
|
+
const PARTS_PER_GRANT = 8;
|
|
23
|
+
const AT_ONCE = 4;
|
|
24
|
+
const ATTEMPTS = 5;
|
|
25
|
+
/** A URL with less than this left is asked for again rather than started. */
|
|
26
|
+
const URL_MARGIN_MS = 60_000;
|
|
27
|
+
|
|
28
|
+
/** The file's multipart upload can take no more parts (`hyperframes_composition_upload_file_not_writable`): this
|
|
29
|
+
* upload cannot finish, so its owner must resolve it before a retry can begin a new one. */
|
|
30
|
+
export class NotWritable extends Error {
|
|
31
|
+
constructor() {
|
|
32
|
+
super("composition sync: a file's multipart upload can no longer take parts");
|
|
33
|
+
this.name = "NotWritable";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** @typedef {import("./types.js").UploadJob} UploadJob */
|
|
38
|
+
|
|
39
|
+
/** @param {UploadJob} job @param {unknown} error @param {number} attempt the attempt that just failed, from 1
|
|
40
|
+
* @returns {Promise<boolean>} whether to try again */
|
|
41
|
+
async function againAfter(job, error, attempt) {
|
|
42
|
+
if (attempt >= ATTEMPTS || isAbort(error)) return false;
|
|
43
|
+
const transient =
|
|
44
|
+
(error instanceof TransferError && (error.kind === "transient" || error.kind === "corrupt")) ||
|
|
45
|
+
(error instanceof SyncApiError &&
|
|
46
|
+
["offline", "unavailable", "server-error", "rate-limited"].includes(error.kind));
|
|
47
|
+
if (!transient) return false;
|
|
48
|
+
const asked = error instanceof SyncApiError ? error.retryAfterMs : null;
|
|
49
|
+
await job.wait(backoffMs(attempt, asked), job.signal);
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A refusal of a pinned multipart id, which its refusal says to drop and ask again without.
|
|
54
|
+
* @param {unknown} error @param {GrantRequest[]} asked */
|
|
55
|
+
const wrongPinnedId = (error, asked) =>
|
|
56
|
+
error instanceof SyncApiError &&
|
|
57
|
+
error.kind === "invalid-parameter" &&
|
|
58
|
+
(error.param ?? "").endsWith("multipart_upload_id") &&
|
|
59
|
+
asked.some((file) => file.multipartUploadId);
|
|
60
|
+
|
|
61
|
+
/** Write grants for `files`, sent again on a transient failure. A file that can take no more parts throws NotWritable.
|
|
62
|
+
* @param {UploadJob} job @param {GrantRequest[]} files @returns {Promise<FileGrant[]>} */
|
|
63
|
+
async function grant(job, files) {
|
|
64
|
+
let asked = files;
|
|
65
|
+
for (let attempt = 1; ; attempt++) {
|
|
66
|
+
try {
|
|
67
|
+
return await job.api.uploadUrls(job.space, job.composition, job.uploadId, asked);
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (error instanceof SyncApiError && error.kind === "file-not-writable")
|
|
70
|
+
throw new NotWritable();
|
|
71
|
+
if (wrongPinnedId(error, asked))
|
|
72
|
+
asked = asked.map(({ multipartUploadId: _dropped, ...file }) => file);
|
|
73
|
+
else if (!(await againAfter(job, error, attempt))) throw error;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** @param {UploadJob} job @param {string} sha256 */
|
|
79
|
+
function localFile(job, sha256) {
|
|
80
|
+
const file = job.local.get(sha256);
|
|
81
|
+
if (!file) throw new Error(`composition sync: needed file ${sha256} is not in the capture`);
|
|
82
|
+
return file;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** @typedef {{ url: string, headers: Record<string, string>, expiresAt: string }} Signed
|
|
86
|
+
* @typedef {{ number: number, sha256: string, start: number, length: number }} Part
|
|
87
|
+
* @typedef {{ file: { path: string, full: string }, start: number, length: number, sha256: string }} Bytes */
|
|
88
|
+
|
|
89
|
+
/** One PUT attempt: `done` once stored (or found already stored), `expired` when storage says the URL has, else the
|
|
90
|
+
* failure. Bytes counted for an attempt that failed are taken back.
|
|
91
|
+
* @param {UploadJob} job @param {Signed} held @param {Bytes} bytes
|
|
92
|
+
* @returns {Promise<"done" | "expired" | { failed: unknown }>} */
|
|
93
|
+
async function attemptPut(job, held, { file, ...range }) {
|
|
94
|
+
let sent = 0;
|
|
95
|
+
try {
|
|
96
|
+
await putRange({
|
|
97
|
+
fetchImpl: job.fetchImpl,
|
|
98
|
+
url: held.url,
|
|
99
|
+
headers: held.headers,
|
|
100
|
+
file: file.full,
|
|
101
|
+
path: file.path,
|
|
102
|
+
...range,
|
|
103
|
+
signal: job.signal,
|
|
104
|
+
onBytes: (count) => ((sent += count), job.onBytes(count)),
|
|
105
|
+
});
|
|
106
|
+
return "done";
|
|
107
|
+
} catch (error) {
|
|
108
|
+
job.onBytes(-sent);
|
|
109
|
+
if (!(error instanceof TransferError)) return { failed: error };
|
|
110
|
+
if (error.kind === "exists") return (job.onBytes(range.length), "done");
|
|
111
|
+
return error.kind === "expired" ? "expired" : { failed: error };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Writes `bytes` through `given`, or a fresh grant when it is missing or about to end; asks again when storage says
|
|
116
|
+
* the URL ended, and sends again after a transient failure.
|
|
117
|
+
* @param {UploadJob} job @param {Signed | null} given @param {() => Promise<Signed>} fresh @param {Bytes} bytes */
|
|
118
|
+
async function putWithGrant(job, given, fresh, bytes) {
|
|
119
|
+
const usable = (/** @type {Signed | null} */ signed) =>
|
|
120
|
+
signed && Date.parse(signed.expiresAt) - job.now() >= URL_MARGIN_MS ? signed : null;
|
|
121
|
+
let held = usable(given);
|
|
122
|
+
for (let attempt = 1; ; attempt++) {
|
|
123
|
+
held ??= await fresh();
|
|
124
|
+
const outcome = await attemptPut(job, held, bytes);
|
|
125
|
+
if (outcome === "done") return;
|
|
126
|
+
if (outcome === "expired" && attempt < ATTEMPTS) held = null;
|
|
127
|
+
else {
|
|
128
|
+
const failed = outcome === "expired" ? new TransferError("expired") : outcome.failed;
|
|
129
|
+
if (!(await againAfter(job, failed, attempt))) throw failed;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** @param {FileGrant | undefined} held @returns {Signed} */
|
|
135
|
+
function singleUrl(held) {
|
|
136
|
+
if (!held?.url) throw new Error("composition sync: a single-PUT grant came without its URL");
|
|
137
|
+
return { url: held.url, headers: held.headers, expiresAt: held.expiresAt };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Files up to 64 MiB: grants a hundred at a time, written four at once. @param {UploadJob} job
|
|
141
|
+
* @param {{ sha256: string, size: number }[]} blobs */
|
|
142
|
+
async function sendSmall(job, blobs) {
|
|
143
|
+
for (let at = 0; at < blobs.length; at += GRANT_FILES) {
|
|
144
|
+
const batch = blobs.slice(at, at + GRANT_FILES);
|
|
145
|
+
const grants = await grant(
|
|
146
|
+
job,
|
|
147
|
+
batch.map((blob) => ({ sha256: blob.sha256 })),
|
|
148
|
+
);
|
|
149
|
+
const bySha = new Map(grants.map((one) => [one.sha256, one]));
|
|
150
|
+
await eachLimit(batch, AT_ONCE, async (blob) => {
|
|
151
|
+
const given = bySha.has(blob.sha256) ? singleUrl(bySha.get(blob.sha256)) : null;
|
|
152
|
+
const fresh = async () => singleUrl((await grant(job, [{ sha256: blob.sha256 }]))[0]);
|
|
153
|
+
const bytes = {
|
|
154
|
+
file: localFile(job, blob.sha256),
|
|
155
|
+
start: 0,
|
|
156
|
+
length: blob.size,
|
|
157
|
+
sha256: blob.sha256,
|
|
158
|
+
};
|
|
159
|
+
await putWithGrant(job, given, fresh, bytes);
|
|
160
|
+
(job.inflight.sent ??= []).push(blob.sha256);
|
|
161
|
+
job.persist();
|
|
162
|
+
job.onFile();
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Grants for `parts` of a multipart file, naming its pinned upload once one is known, and keeping the id the server
|
|
168
|
+
* pins. @param {UploadJob} job @param {{ sha256: string }} blob @param {Part[]} parts */
|
|
169
|
+
async function partGrants(job, blob, parts) {
|
|
170
|
+
const pinned = job.inflight.multipart?.[blob.sha256];
|
|
171
|
+
const [held] = await grant(job, [
|
|
172
|
+
{
|
|
173
|
+
sha256: blob.sha256,
|
|
174
|
+
...(pinned && { multipartUploadId: pinned.uploadId }),
|
|
175
|
+
parts: parts.map((part) => ({ partNumber: part.number, sha256: part.sha256 })),
|
|
176
|
+
},
|
|
177
|
+
]);
|
|
178
|
+
if (!held?.multipartUploadId)
|
|
179
|
+
throw new Error("composition sync: a multipart grant came without its upload id");
|
|
180
|
+
// Parts recorded as written belong to the pinned upload; another id would leave them unwritten.
|
|
181
|
+
if (pinned && pinned.uploadId !== held.multipartUploadId) throw new NotWritable();
|
|
182
|
+
if (!pinned) {
|
|
183
|
+
job.inflight.multipart = {
|
|
184
|
+
...job.inflight.multipart,
|
|
185
|
+
[blob.sha256]: { uploadId: held.multipartUploadId, partsDone: [] },
|
|
186
|
+
};
|
|
187
|
+
job.persist();
|
|
188
|
+
}
|
|
189
|
+
return { expiresAt: held.expiresAt, parts: held.parts };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** The file's parts still to write, each with its SHA-256; throws `changed` when the file no longer hashes to the
|
|
193
|
+
* captured SHA-256. @param {UploadJob} job @param {{ sha256: string, size: number }} blob
|
|
194
|
+
* @returns {Promise<Part[]>} */
|
|
195
|
+
async function partsToSend(job, blob) {
|
|
196
|
+
const file = localFile(job, blob.sha256);
|
|
197
|
+
const digests = await partDigests(file.full, blob.size, PART_BYTES, job.signal).catch((error) => {
|
|
198
|
+
if (isAbort(error)) throw error;
|
|
199
|
+
throw new TransferError("unreadable", { path: file.path });
|
|
200
|
+
});
|
|
201
|
+
if (digests.whole !== blob.sha256 || !digests.complete)
|
|
202
|
+
throw new TransferError("changed", { path: file.path });
|
|
203
|
+
const done = new Set(job.inflight.multipart?.[blob.sha256]?.partsDone ?? []);
|
|
204
|
+
return digests.parts
|
|
205
|
+
.map((sha256, index) => ({
|
|
206
|
+
number: index + 1,
|
|
207
|
+
sha256,
|
|
208
|
+
start: index * PART_BYTES,
|
|
209
|
+
length: Math.min(PART_BYTES, blob.size - index * PART_BYTES),
|
|
210
|
+
}))
|
|
211
|
+
.filter((part) => !done.has(part.number));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** A file over 64 MiB, eight parts a grant, four parts at once; its size and time are compared after, so a file that
|
|
215
|
+
* changed while its parts were read fails as changed. @param {UploadJob} job
|
|
216
|
+
* @param {{ sha256: string, size: number }} blob */
|
|
217
|
+
async function sendLarge(job, blob) {
|
|
218
|
+
const file = localFile(job, blob.sha256);
|
|
219
|
+
const before = await lstat(file.full);
|
|
220
|
+
const parts = await partsToSend(job, blob);
|
|
221
|
+
job.onBytes(blob.size - parts.reduce((sum, part) => sum + part.length, 0));
|
|
222
|
+
for (let at = 0; at < parts.length; at += PARTS_PER_GRANT) {
|
|
223
|
+
const batch = parts.slice(at, at + PARTS_PER_GRANT);
|
|
224
|
+
const granted = await partGrants(job, blob, batch);
|
|
225
|
+
const byNumber = new Map(granted.parts.map((part) => [part.partNumber, part]));
|
|
226
|
+
await eachLimit(batch, AT_ONCE, async (part) => {
|
|
227
|
+
const signed = byNumber.get(part.number);
|
|
228
|
+
const given = signed
|
|
229
|
+
? { url: signed.url, headers: signed.headers, expiresAt: granted.expiresAt }
|
|
230
|
+
: null;
|
|
231
|
+
const fresh = async () => {
|
|
232
|
+
const again = await partGrants(job, blob, [part]);
|
|
233
|
+
const one = again.parts[0];
|
|
234
|
+
if (!one) throw new Error("composition sync: a part grant came without its part");
|
|
235
|
+
return { url: one.url, headers: one.headers, expiresAt: again.expiresAt };
|
|
236
|
+
};
|
|
237
|
+
await putWithGrant(job, given, fresh, { file, ...part });
|
|
238
|
+
job.inflight.multipart?.[blob.sha256]?.partsDone.push(part.number);
|
|
239
|
+
job.persist();
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
const after = await lstat(file.full).catch(() => null);
|
|
243
|
+
if (!after || after.size !== before.size || after.mtimeMs !== before.mtimeMs)
|
|
244
|
+
throw new TransferError("changed", { path: file.path });
|
|
245
|
+
(job.inflight.sent ??= []).push(blob.sha256);
|
|
246
|
+
job.persist();
|
|
247
|
+
job.onFile();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Writes every needed file the in-flight record does not list as sent. @param {UploadJob} job */
|
|
251
|
+
export async function uploadNeeded(job) {
|
|
252
|
+
const sent = new Set(job.inflight.sent ?? []);
|
|
253
|
+
const waiting = job.needed.filter((blob) => !sent.has(blob.sha256));
|
|
254
|
+
for (const blob of job.needed.filter((one) => sent.has(one.sha256))) {
|
|
255
|
+
job.onBytes(blob.size);
|
|
256
|
+
job.onFile();
|
|
257
|
+
}
|
|
258
|
+
await sendSmall(
|
|
259
|
+
job,
|
|
260
|
+
waiting.filter((blob) => blob.size <= PART_BYTES),
|
|
261
|
+
);
|
|
262
|
+
for (const blob of waiting.filter((one) => one.size > PART_BYTES)) await sendLarge(job, blob);
|
|
263
|
+
}
|