@bota.dev/react-native-app-sdk 2.0.0-beta.13 → 2.0.0-beta.14
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 +20 -7
- package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordingUploads.kt +2 -2
- package/android/src/test/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordingUploadsTest.kt +23 -0
- package/generated/codegen-contract.json +2 -2
- package/ios/BotaDeviceSDKAppleRecordingUploads.swift +3 -3
- package/lib/commonjs/managers/NativeUploadQueue.js +22 -8
- package/lib/commonjs/managers/NativeUploadQueue.js.map +1 -1
- package/lib/commonjs/managers/uploadRecoveryMetadata.js +4 -0
- package/lib/commonjs/managers/uploadRecoveryMetadata.js.map +1 -1
- package/lib/commonjs/managers/uploadVerificationPending.js +20 -0
- package/lib/commonjs/managers/uploadVerificationPending.js.map +1 -0
- package/lib/commonjs/sdkIdentity.js +1 -1
- package/lib/commonjs/uploadRecovery.js +50 -7
- package/lib/commonjs/uploadRecovery.js.map +1 -1
- package/lib/module/managers/NativeUploadQueue.js +22 -8
- package/lib/module/managers/NativeUploadQueue.js.map +1 -1
- package/lib/module/managers/uploadRecoveryMetadata.js +4 -0
- package/lib/module/managers/uploadRecoveryMetadata.js.map +1 -1
- package/lib/module/managers/uploadVerificationPending.js +14 -0
- package/lib/module/managers/uploadVerificationPending.js.map +1 -0
- package/lib/module/sdkIdentity.js +1 -1
- package/lib/module/uploadRecovery.js +50 -7
- package/lib/module/uploadRecovery.js.map +1 -1
- package/lib/typescript/managers/NativeUploadQueue.d.ts.map +1 -1
- package/lib/typescript/managers/uploadRecoveryMetadata.d.ts.map +1 -1
- package/lib/typescript/managers/uploadVerificationPending.d.ts +6 -0
- package/lib/typescript/managers/uploadVerificationPending.d.ts.map +1 -0
- package/lib/typescript/models/Recording.d.ts +6 -0
- package/lib/typescript/models/Recording.d.ts.map +1 -1
- package/lib/typescript/sdkIdentity.d.ts +1 -1
- package/lib/typescript/uploadRecovery.d.ts +1 -1
- package/lib/typescript/uploadRecovery.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/managers/NativeUploadQueue.ts +18 -9
- package/src/managers/uploadRecoveryMetadata.ts +2 -0
- package/src/managers/uploadVerificationPending.ts +10 -0
- package/src/models/Recording.ts +6 -0
- package/src/sdkIdentity.ts +1 -1
- package/src/uploadRecovery.ts +42 -8
|
@@ -20,6 +20,7 @@ export function persistedUploadQueue(tasks: UploadTask[]): UploadTask[] {
|
|
|
20
20
|
...(task.nextAttemptAt === undefined ? {} : { nextAttemptAt: task.nextAttemptAt }),
|
|
21
21
|
...(task.contentType === undefined ? {} : { contentType: task.contentType }),
|
|
22
22
|
...(task.contentSha256 === undefined ? {} : { contentSha256: task.contentSha256 }),
|
|
23
|
+
...(task.completionPending === undefined ? {} : { completionPending: task.completionPending }),
|
|
23
24
|
relayUpload: task.relayUpload ?? !!task.relay,
|
|
24
25
|
}));
|
|
25
26
|
}
|
|
@@ -44,6 +45,7 @@ export function restoreUploadQueue(value: unknown): UploadTask[] {
|
|
|
44
45
|
if (task[field] !== undefined && (!Number.isSafeInteger(task[field]) || task[field]! < 0)) return invalid();
|
|
45
46
|
}
|
|
46
47
|
if (task.relayUpload !== undefined && typeof task.relayUpload !== 'boolean') return invalid();
|
|
48
|
+
if (task.completionPending !== undefined && typeof task.completionPending !== 'boolean') return invalid();
|
|
47
49
|
const createdAt = new Date(task.createdAt);
|
|
48
50
|
const updatedAt = new Date(task.updatedAt);
|
|
49
51
|
if (!Number.isFinite(createdAt.getTime()) || !Number.isFinite(updatedAt.getTime())) return invalid();
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Internal completion-budget signal; it is never acknowledgement or repair. */
|
|
2
|
+
export class UploadVerificationPendingError extends Error {
|
|
3
|
+
constructor() { super('Upload verification pending'); }
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function isUploadVerificationPending(error: unknown): boolean {
|
|
7
|
+
if (!error || typeof error !== 'object') return false;
|
|
8
|
+
const value = error as { status?: unknown; data?: { error?: { code?: unknown } } };
|
|
9
|
+
return value.status === 425 && value.data?.error?.code === 'upload_verification_pending';
|
|
10
|
+
}
|
package/src/models/Recording.ts
CHANGED
|
@@ -34,6 +34,8 @@ export interface UploadInfo {
|
|
|
34
34
|
recoveryScope?: string;
|
|
35
35
|
/** Skip sending the object; complete must still acknowledge host durability. */
|
|
36
36
|
alreadyUploaded?: boolean;
|
|
37
|
+
/** Explicit authenticated integrity failure permits repair of a completion-pending object. */
|
|
38
|
+
reuploadRequired?: boolean;
|
|
37
39
|
/** Resolve only after the backend durably acknowledges this exact recording. */
|
|
38
40
|
complete?: (context: {
|
|
39
41
|
fileSizeBytes: number;
|
|
@@ -92,6 +94,8 @@ export interface UploadRecoveryContext {
|
|
|
92
94
|
relayUpload: boolean;
|
|
93
95
|
contentType?: string;
|
|
94
96
|
contentSha256?: string;
|
|
97
|
+
/** Bytes were sent; recover completion before considering another upload. Not verification proof. */
|
|
98
|
+
completionPending?: boolean;
|
|
95
99
|
signal: AbortSignal;
|
|
96
100
|
}
|
|
97
101
|
|
|
@@ -152,6 +156,8 @@ export interface UploadTask {
|
|
|
152
156
|
fileSizeBytes?: number;
|
|
153
157
|
relayUpload?: boolean;
|
|
154
158
|
nextAttemptAt?: number;
|
|
159
|
+
/** Durable byte-upload completion; backend acknowledgement and cleanup are still required. */
|
|
160
|
+
completionPending?: boolean;
|
|
155
161
|
/** Volatile compatibility field; never included in queue persistence. */
|
|
156
162
|
complete?: UploadInfo['complete'];
|
|
157
163
|
/** Unique task identifier */
|
package/src/sdkIdentity.ts
CHANGED
package/src/uploadRecovery.ts
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
1
|
import type { UploadInfo, UploadRecoveryContext, UploadRecoveryProvider } from './models/Recording';
|
|
2
|
+
import { isUploadVerificationPending, UploadVerificationPendingError } from './managers/uploadVerificationPending';
|
|
3
|
+
|
|
4
|
+
const waitForCompletion = (milliseconds: number, signals: AbortSignal[]): Promise<void> =>
|
|
5
|
+
new Promise((resolve, reject) => {
|
|
6
|
+
const unique = [...new Set(signals)];
|
|
7
|
+
const clean = () => { clearTimeout(timer); unique.forEach(signal => signal.removeEventListener('abort', abort)); };
|
|
8
|
+
const abort = () => { clean(); reject(new Error('Upload recovery cancelled')); };
|
|
9
|
+
const timer = setTimeout(() => { clean(); resolve(); }, milliseconds);
|
|
10
|
+
unique.forEach(signal => signal.addEventListener('abort', abort, { once: true }));
|
|
11
|
+
if (unique.some(signal => signal.aborted)) abort();
|
|
12
|
+
});
|
|
2
13
|
|
|
3
14
|
/** A host-owned, authenticated lease. It must permanently reject changed identities. */
|
|
4
15
|
export interface UploadRecoveryBackend {
|
|
@@ -8,7 +19,7 @@ export interface UploadRecoveryBackend {
|
|
|
8
19
|
dispose(): void;
|
|
9
20
|
getRecording(id: string, signal: AbortSignal): Promise<{
|
|
10
21
|
id: string; status: string; metadata?: Record<string, unknown>;
|
|
11
|
-
content_sha256?: string | null; file_size_bytes?: number | null;
|
|
22
|
+
content_sha256?: string | null; file_size_bytes?: number | string | null;
|
|
12
23
|
}>;
|
|
13
24
|
getUploadInfo(id: string, relay: boolean, contentType: string | undefined, signal: AbortSignal): Promise<Pick<UploadInfo, 'uploadUrl' | 'relay' | 'contentType'>>;
|
|
14
25
|
completeRecording(id: string, data: { duration_seconds?: number; file_size_bytes: number; content_sha256?: string }, signal: AbortSignal): Promise<unknown>;
|
|
@@ -36,26 +47,49 @@ export function createUploadRecoveryProvider(
|
|
|
36
47
|
check();
|
|
37
48
|
const recording = await api.getRecording(task.recordingId, task.signal);
|
|
38
49
|
check();
|
|
50
|
+
const wireSize = recording.file_size_bytes;
|
|
51
|
+
if (typeof wireSize === 'string' && !/^(0|[1-9][0-9]*)$/.test(wireSize)) throw new Error('Invalid upload recovery file size');
|
|
52
|
+
const fileSize = typeof wireSize === 'string' ? Number(wireSize) : wireSize;
|
|
53
|
+
if (fileSize != null && (typeof fileSize !== 'number' || !Number.isSafeInteger(fileSize) || fileSize < 0)) {
|
|
54
|
+
throw new Error('Invalid upload recovery file size');
|
|
55
|
+
}
|
|
39
56
|
if (recording.id !== task.recordingId || (!task.relayUpload && (
|
|
40
57
|
(task.contentSha256 && recording.content_sha256 && task.contentSha256 !== recording.content_sha256) ||
|
|
41
|
-
(task.fileSizeBytes !== undefined &&
|
|
58
|
+
(task.fileSizeBytes !== undefined && fileSize != null && task.fileSizeBytes !== fileSize)
|
|
42
59
|
))) throw new Error('Upload recovery identity or evidence changed');
|
|
43
60
|
if (!['pending', 'uploaded', 'integrity_failure'].includes(recording.status)) throw new Error('Recording unavailable for recovery');
|
|
44
61
|
const start = Date.parse(String(recording.metadata?.started_at ?? ''));
|
|
45
62
|
const end = Date.parse(String(recording.metadata?.ended_at ?? ''));
|
|
46
63
|
const duration = Number.isFinite(start) && Number.isFinite(end) && end >= start ? Math.floor((end - start) / 1000) : undefined;
|
|
47
|
-
const
|
|
64
|
+
const alreadyUploaded = recording.status === 'uploaded' || (task.completionPending === true && recording.status === 'pending');
|
|
65
|
+
const target = alreadyUploaded ? { uploadUrl: '' }
|
|
48
66
|
: await api.getUploadInfo(task.recordingId, task.relayUpload, task.contentType, task.signal);
|
|
49
67
|
check();
|
|
50
68
|
return {
|
|
51
69
|
...target, recordingId: task.recordingId, recoveryScope: task.recoveryScope,
|
|
52
|
-
alreadyUploaded: recording.status === '
|
|
70
|
+
alreadyUploaded, reuploadRequired: recording.status === 'integrity_failure',
|
|
71
|
+
signal: api.signal, dispose: () => api.dispose(),
|
|
53
72
|
complete: async ({ fileSizeBytes, contentSha256, signal }) => {
|
|
54
|
-
|
|
55
|
-
await api.completeRecording(task.recordingId, {
|
|
73
|
+
const data = {
|
|
56
74
|
duration_seconds: duration, file_size_bytes: fileSizeBytes, content_sha256: contentSha256,
|
|
57
|
-
}
|
|
58
|
-
|
|
75
|
+
};
|
|
76
|
+
const deadline = Date.now() + 120_000;
|
|
77
|
+
for (let poll = 0; ; poll++) {
|
|
78
|
+
check(signal);
|
|
79
|
+
if (poll > 0 && (Date.now() >= deadline || poll >= 120)) throw new UploadVerificationPendingError();
|
|
80
|
+
try {
|
|
81
|
+
await api.completeRecording(task.recordingId, data, signal);
|
|
82
|
+
check(signal);
|
|
83
|
+
return;
|
|
84
|
+
} catch (error) {
|
|
85
|
+
check(signal);
|
|
86
|
+
if (!isUploadVerificationPending(error)) throw error;
|
|
87
|
+
const remaining = deadline - Date.now();
|
|
88
|
+
if (remaining <= 0) throw new UploadVerificationPendingError();
|
|
89
|
+
await waitForCompletion(Math.min(1_000 + Math.floor(Math.random() * 1_000), remaining),
|
|
90
|
+
[signal, task.signal, api.signal]);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
59
93
|
},
|
|
60
94
|
};
|
|
61
95
|
} catch {
|