@freqhole/api-client 0.1.31 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -17
- package/src/CharnelTransport.ts +7 -7
- package/src/WasmTransport.ts +23 -22
- package/src/domains/admin.ts +23 -0
- package/src/permissions.ts +16 -29
- package/src/pollingJobEvents.test.ts +192 -0
- package/src/test/permissions.ts +185 -0
- package/src/test.ts +5 -23
- package/src/transport.ts +10 -3
- package/vitest.config.ts +8 -0
- package/src/automerge/IrohNetworkAdapter.ts +0 -727
- package/src/automerge/index.ts +0 -14
- package/src/storage/blobs.ts +0 -272
- package/src/storage/identity.ts +0 -190
- package/src/storage/index.ts +0 -5
- package/src/storage/webLocks.ts +0 -92
- package/src/test/automerge.ts +0 -796
- package/src/test/playlistz.ts +0 -631
- package/src/test/storage.ts +0 -443
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@freqhole/api-client",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -11,36 +11,26 @@
|
|
|
11
11
|
"access": "public"
|
|
12
12
|
},
|
|
13
13
|
"exports": {
|
|
14
|
-
".": "./src/index.ts"
|
|
15
|
-
"./storage": "./src/storage/index.ts",
|
|
16
|
-
"./playlistz": "./src/playlistz/index.ts",
|
|
17
|
-
"./automerge": "./src/automerge/index.ts"
|
|
14
|
+
".": "./src/index.ts"
|
|
18
15
|
},
|
|
19
16
|
"scripts": {
|
|
20
17
|
"typecheck": "tsc --noEmit",
|
|
21
18
|
"test": "tsx src/test.ts",
|
|
22
19
|
"test:coverage": "SKIP_INTEGRATION=true tsx src/test.ts",
|
|
23
20
|
"test:integration": "tsx src/test.ts",
|
|
21
|
+
"test:unit": "vitest run",
|
|
24
22
|
"auth-test": "vite --config vite.config.js"
|
|
25
23
|
},
|
|
26
24
|
"dependencies": {
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
},
|
|
30
|
-
"peerDependencies": {
|
|
31
|
-
"@automerge/automerge-repo": "^2.5.5"
|
|
32
|
-
},
|
|
33
|
-
"peerDependenciesMeta": {
|
|
34
|
-
"@automerge/automerge-repo": {
|
|
35
|
-
"optional": true
|
|
36
|
-
}
|
|
25
|
+
"zod": "^4.3.5",
|
|
26
|
+
"@freqhole/haruspex": "file:../../lib/haruspex/ts"
|
|
37
27
|
},
|
|
38
28
|
"devDependencies": {
|
|
39
|
-
"@automerge/automerge-repo": "^2.5.6",
|
|
40
29
|
"@tauri-apps/api": "^2.10.1",
|
|
41
30
|
"@types/node": "^25.0.8",
|
|
42
31
|
"tsx": "^4.7.0",
|
|
32
|
+
"vitest": "^2.1.0",
|
|
43
33
|
"typescript": "^5.3.0",
|
|
44
34
|
"vite": "^5.4.21"
|
|
45
35
|
}
|
|
46
|
-
}
|
|
36
|
+
}
|
package/src/CharnelTransport.ts
CHANGED
|
@@ -128,12 +128,12 @@ export class CharnelTransport implements Transport {
|
|
|
128
128
|
const inv = await ensureInvoke();
|
|
129
129
|
|
|
130
130
|
// intentionally no per-error debug log here: the underlying
|
|
131
|
-
//
|
|
131
|
+
// p2p_api_call command already traces failures on the rust
|
|
132
132
|
// side, and callers (auth-status, health-check, etc.) decide
|
|
133
133
|
// whether to surface the error. logging at this layer fires once
|
|
134
134
|
// per request per failed peer and drowns the console for any
|
|
135
135
|
// peer that's temporarily unreachable.
|
|
136
|
-
const result = (await inv("
|
|
136
|
+
const result = (await inv("p2p_api_call", {
|
|
137
137
|
peerAddr: this.peerAddr,
|
|
138
138
|
method,
|
|
139
139
|
path,
|
|
@@ -218,7 +218,7 @@ export class CharnelTransport implements Transport {
|
|
|
218
218
|
);
|
|
219
219
|
}
|
|
220
220
|
|
|
221
|
-
// for non-music uploads, send path + metadata via
|
|
221
|
+
// for non-music uploads, send path + metadata via api_request
|
|
222
222
|
const body: Record<string, unknown> = {
|
|
223
223
|
file_path: filePath,
|
|
224
224
|
...metadata,
|
|
@@ -253,7 +253,7 @@ export class CharnelTransport implements Transport {
|
|
|
253
253
|
}
|
|
254
254
|
}
|
|
255
255
|
|
|
256
|
-
// send via
|
|
256
|
+
// send via api_request — routes through offal dispatch on the remote peer
|
|
257
257
|
return this.request("POST", path, JSON.stringify(body));
|
|
258
258
|
}
|
|
259
259
|
|
|
@@ -311,7 +311,7 @@ export class CharnelTransport implements Transport {
|
|
|
311
311
|
/**
|
|
312
312
|
* fetch a blob via P2P
|
|
313
313
|
* if blake3 is provided, uses iroh-blobs verified streaming.
|
|
314
|
-
* for blobs without blake3 (images, waveforms, thumbnails), uses
|
|
314
|
+
* for blobs without blake3 (images, waveforms, thumbnails), uses api_request
|
|
315
315
|
* to fetch base64-encoded data from the peer's /api/blobs/{id}/data endpoint.
|
|
316
316
|
*/
|
|
317
317
|
async fetchBlob(blobId: string, blake3?: string): Promise<BlobData> {
|
|
@@ -334,7 +334,7 @@ export class CharnelTransport implements Transport {
|
|
|
334
334
|
};
|
|
335
335
|
}
|
|
336
336
|
|
|
337
|
-
// no blake3 — try
|
|
337
|
+
// no blake3 — try api_request to get blob data from database
|
|
338
338
|
// this is the primary path for images (waveforms, thumbnails) stored in the database
|
|
339
339
|
try {
|
|
340
340
|
const result = await this.request("GET", `/api/blobs/${blobId}/data`);
|
|
@@ -349,7 +349,7 @@ export class CharnelTransport implements Transport {
|
|
|
349
349
|
} catch (e) {
|
|
350
350
|
const errorMessage = e instanceof Error ? e.message : String(e);
|
|
351
351
|
console.warn(
|
|
352
|
-
`[CharnelTransport]
|
|
352
|
+
`[CharnelTransport] api blob data request failed, falling back to verified download: ${errorMessage}`,
|
|
353
353
|
);
|
|
354
354
|
}
|
|
355
355
|
|
package/src/WasmTransport.ts
CHANGED
|
@@ -45,7 +45,7 @@ export interface BiStreamLike {
|
|
|
45
45
|
export interface MiddenNodeLike {
|
|
46
46
|
node_id(): string;
|
|
47
47
|
secret_key(): Uint8Array;
|
|
48
|
-
|
|
48
|
+
api_request(
|
|
49
49
|
peer_addr: string,
|
|
50
50
|
method: string,
|
|
51
51
|
path: string,
|
|
@@ -218,7 +218,7 @@ export class WasmTransport implements Transport {
|
|
|
218
218
|
body?: string,
|
|
219
219
|
): Promise<TransportResponse> {
|
|
220
220
|
try {
|
|
221
|
-
const result = await this.node.
|
|
221
|
+
const result = await this.node.api_request(
|
|
222
222
|
this.peerAddr,
|
|
223
223
|
method,
|
|
224
224
|
path,
|
|
@@ -260,7 +260,7 @@ export class WasmTransport implements Transport {
|
|
|
260
260
|
return this.uploadViaIrohBlobs(file, formData);
|
|
261
261
|
}
|
|
262
262
|
|
|
263
|
-
// fallback: base64 encode and send via
|
|
263
|
+
// fallback: base64 encode and send via api_request (works for image uploads)
|
|
264
264
|
return this.uploadViaBase64(path, file, formData);
|
|
265
265
|
}
|
|
266
266
|
|
|
@@ -363,7 +363,7 @@ export class WasmTransport implements Transport {
|
|
|
363
363
|
}
|
|
364
364
|
}
|
|
365
365
|
|
|
366
|
-
// send via
|
|
366
|
+
// send via api_request — routes through offal dispatch on the remote peer
|
|
367
367
|
return this.request("POST", path, JSON.stringify(body));
|
|
368
368
|
}
|
|
369
369
|
|
|
@@ -407,15 +407,15 @@ export class WasmTransport implements Transport {
|
|
|
407
407
|
console.warn(
|
|
408
408
|
`[WasmTransport] verified download failed, falling back: ${errorMessage}`,
|
|
409
409
|
);
|
|
410
|
-
// fall through to
|
|
410
|
+
// fall through to api fetch
|
|
411
411
|
}
|
|
412
412
|
}
|
|
413
413
|
}
|
|
414
414
|
|
|
415
|
-
// no blake3 (or verified download failed) — try
|
|
415
|
+
// no blake3 (or verified download failed) — try api_request to get blob data
|
|
416
416
|
// this is the primary path for images (waveforms, thumbnails) stored in the database
|
|
417
417
|
try {
|
|
418
|
-
const result = await this.node.
|
|
418
|
+
const result = await this.node.api_request(
|
|
419
419
|
this.peerAddr,
|
|
420
420
|
"GET",
|
|
421
421
|
`/api/blobs/${blobId}/data`,
|
|
@@ -443,7 +443,7 @@ export class WasmTransport implements Transport {
|
|
|
443
443
|
} catch (e) {
|
|
444
444
|
const errorMessage = e instanceof Error ? e.message : String(e);
|
|
445
445
|
console.warn(
|
|
446
|
-
`[WasmTransport]
|
|
446
|
+
`[WasmTransport] api blob data request failed, falling back: ${errorMessage}`,
|
|
447
447
|
);
|
|
448
448
|
}
|
|
449
449
|
|
|
@@ -653,9 +653,10 @@ export class WasmTransport implements Transport {
|
|
|
653
653
|
}
|
|
654
654
|
|
|
655
655
|
// no blake3 (or verified download failed).
|
|
656
|
-
// for audio blobs, prefer verified-by-id before
|
|
657
|
-
// `/api/blobs/{id}/data` only works for DB-backed blobs
|
|
658
|
-
// media blobs would otherwise fail first, then fall back
|
|
656
|
+
// for audio blobs, prefer verified-by-id before the api_request `/data`
|
|
657
|
+
// fallback because `/api/blobs/{id}/data` only works for DB-backed blobs
|
|
658
|
+
// and file-backed media blobs would otherwise fail first, then fall back
|
|
659
|
+
// anyway.
|
|
659
660
|
if (
|
|
660
661
|
!blake3 &&
|
|
661
662
|
this.node.download_verified_by_id &&
|
|
@@ -684,15 +685,15 @@ export class WasmTransport implements Transport {
|
|
|
684
685
|
} catch (e) {
|
|
685
686
|
const errorMessage = e instanceof Error ? e.message : String(e);
|
|
686
687
|
console.warn(
|
|
687
|
-
`[WasmTransport] audio verified-by-id failed, falling back to
|
|
688
|
+
`[WasmTransport] audio verified-by-id failed, falling back to api_request: ${errorMessage}`,
|
|
688
689
|
);
|
|
689
690
|
}
|
|
690
691
|
}
|
|
691
692
|
|
|
692
|
-
// try
|
|
693
|
-
let
|
|
693
|
+
// try api_request to get blob data
|
|
694
|
+
let apiFailureReason: string | null = null;
|
|
694
695
|
try {
|
|
695
|
-
const result = await this.node.
|
|
696
|
+
const result = await this.node.api_request(
|
|
696
697
|
this.peerAddr,
|
|
697
698
|
"GET",
|
|
698
699
|
`/api/blobs/${blobId}/data`,
|
|
@@ -719,17 +720,17 @@ export class WasmTransport implements Transport {
|
|
|
719
720
|
|
|
720
721
|
return { data, contentType };
|
|
721
722
|
}
|
|
722
|
-
|
|
723
|
-
console.warn(`[WasmTransport] ${
|
|
723
|
+
apiFailureReason = `success=false in api response body for blob ${blobId}`;
|
|
724
|
+
console.warn(`[WasmTransport] ${apiFailureReason}`);
|
|
724
725
|
} else {
|
|
725
|
-
|
|
726
|
-
console.warn(`[WasmTransport] ${
|
|
726
|
+
apiFailureReason = `api request returned status ${result.status} for blob ${blobId}`;
|
|
727
|
+
console.warn(`[WasmTransport] ${apiFailureReason}`);
|
|
727
728
|
}
|
|
728
729
|
} catch (e) {
|
|
729
730
|
const errorMessage = e instanceof Error ? e.message : String(e);
|
|
730
|
-
|
|
731
|
+
apiFailureReason = `api request threw: ${errorMessage}`;
|
|
731
732
|
console.warn(
|
|
732
|
-
`[WasmTransport]
|
|
733
|
+
`[WasmTransport] api blob data request failed, falling back: ${errorMessage}`,
|
|
733
734
|
);
|
|
734
735
|
}
|
|
735
736
|
|
|
@@ -804,7 +805,7 @@ export class WasmTransport implements Transport {
|
|
|
804
805
|
throw new Error(
|
|
805
806
|
`no download method available for blob ${blobId} ` +
|
|
806
807
|
`(blake3=${blake3 ? "yes" : "no"}, ` +
|
|
807
|
-
`
|
|
808
|
+
`api=${apiFailureReason ?? "not attempted"}, ` +
|
|
808
809
|
`legacy_fetch_blob=${this.node.fetch_blob ? "available" : "missing"})`,
|
|
809
810
|
);
|
|
810
811
|
}
|
package/src/domains/admin.ts
CHANGED
|
@@ -93,5 +93,28 @@ export function createAdminMethods(call: CallFn) {
|
|
|
93
93
|
routes.admin.get_knock_status_public.path,
|
|
94
94
|
);
|
|
95
95
|
},
|
|
96
|
+
|
|
97
|
+
// internal service callbacks
|
|
98
|
+
deviceLinkedCallback: (params: s.DeviceLinkedCallbackRequest) => {
|
|
99
|
+
return call(
|
|
100
|
+
"admin", "device_linked_callback",
|
|
101
|
+
routes.admin.device_linked_callback.resp,
|
|
102
|
+
routes.admin.device_linked_callback.req,
|
|
103
|
+
routes.admin.device_linked_callback.method,
|
|
104
|
+
routes.admin.device_linked_callback.path,
|
|
105
|
+
params,
|
|
106
|
+
);
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
knockAcceptedCallback: (params: s.KnockAcceptedCallbackRequest) => {
|
|
110
|
+
return call(
|
|
111
|
+
"admin", "knock_accepted_callback",
|
|
112
|
+
routes.admin.knock_accepted_callback.resp,
|
|
113
|
+
routes.admin.knock_accepted_callback.req,
|
|
114
|
+
routes.admin.knock_accepted_callback.method,
|
|
115
|
+
routes.admin.knock_accepted_callback.path,
|
|
116
|
+
params,
|
|
117
|
+
);
|
|
118
|
+
},
|
|
96
119
|
};
|
|
97
120
|
}
|
package/src/permissions.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
// permission helpers for route authorization
|
|
2
2
|
//
|
|
3
3
|
// these are hand-written helpers that use the generated route auth metadata
|
|
4
|
-
// to check if a user has permission to perform actions.
|
|
4
|
+
// to check if a user has permission to perform actions. the core role-
|
|
5
|
+
// hierarchy/route-auth evaluation logic is bound once over this package's
|
|
6
|
+
// own codegen'd role hierarchy from @freqhole/haruspex/permissions - only
|
|
7
|
+
// the entity-specific route lookups below are tomb-specific.
|
|
5
8
|
|
|
9
|
+
import { bindRoleHierarchy, type AccessRule } from "@freqhole/haruspex/permissions";
|
|
6
10
|
import {
|
|
7
11
|
roleHierarchy,
|
|
8
12
|
routes,
|
|
@@ -10,6 +14,8 @@ import {
|
|
|
10
14
|
type UserRoleName,
|
|
11
15
|
} from "./codegen/routes.js";
|
|
12
16
|
|
|
17
|
+
const roleChecks = bindRoleHierarchy(roleHierarchy);
|
|
18
|
+
|
|
13
19
|
// ============================================================================
|
|
14
20
|
// core permission functions
|
|
15
21
|
// ============================================================================
|
|
@@ -26,7 +32,7 @@ export function canAccessRole(
|
|
|
26
32
|
userRole: UserRoleName,
|
|
27
33
|
requiredRole: UserRoleName,
|
|
28
34
|
): boolean {
|
|
29
|
-
return
|
|
35
|
+
return roleChecks.hasAtLeastRole(userRole, requiredRole);
|
|
30
36
|
}
|
|
31
37
|
|
|
32
38
|
/**
|
|
@@ -45,12 +51,7 @@ export function canAccessOwnerOr(
|
|
|
45
51
|
userRole: UserRoleName,
|
|
46
52
|
requiredRole: UserRoleName,
|
|
47
53
|
): boolean {
|
|
48
|
-
|
|
49
|
-
if (ownerId !== null && userId === ownerId) {
|
|
50
|
-
return true;
|
|
51
|
-
}
|
|
52
|
-
// otherwise check role
|
|
53
|
-
return canAccessRole(userRole, requiredRole);
|
|
54
|
+
return roleChecks.canAccessOwnerOr(userId, ownerId, userRole, requiredRole);
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
/**
|
|
@@ -65,7 +66,7 @@ export function canAccessOwner(
|
|
|
65
66
|
userId: string,
|
|
66
67
|
ownerId: string | null,
|
|
67
68
|
): boolean {
|
|
68
|
-
return
|
|
69
|
+
return roleChecks.isOwner(userId, ownerId);
|
|
69
70
|
}
|
|
70
71
|
|
|
71
72
|
/**
|
|
@@ -84,28 +85,14 @@ export function canAccessRoute(
|
|
|
84
85
|
userId: string | null,
|
|
85
86
|
ownerId: string | null,
|
|
86
87
|
): boolean {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
return userRole !== null;
|
|
93
|
-
|
|
94
|
-
case "role":
|
|
95
|
-
return userRole !== null && canAccessRole(userRole, auth.role);
|
|
96
|
-
|
|
97
|
-
case "owner":
|
|
98
|
-
return userId !== null && canAccessOwner(userId, ownerId);
|
|
99
|
-
|
|
100
|
-
case "owner_or":
|
|
101
|
-
return (
|
|
102
|
-
userId !== null &&
|
|
103
|
-
userRole !== null &&
|
|
104
|
-
canAccessOwnerOr(userId, ownerId, userRole, auth.role)
|
|
105
|
-
);
|
|
106
|
-
}
|
|
88
|
+
return roleChecks.canAccess(auth as AccessRule<UserRoleName>, {
|
|
89
|
+
userRole,
|
|
90
|
+
userId,
|
|
91
|
+
ownerId,
|
|
92
|
+
});
|
|
107
93
|
}
|
|
108
94
|
|
|
95
|
+
|
|
109
96
|
// ============================================================================
|
|
110
97
|
// entity-specific permission helpers
|
|
111
98
|
// ============================================================================
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// tests for the http polling fallback's snapshot-diffing state machine.
|
|
2
|
+
//
|
|
3
|
+
// pollingJobEvents synthesizes status_changed/stage events by diffing
|
|
4
|
+
// consecutive snapshots against a retained baseline (`prev`). these
|
|
5
|
+
// tests exist because that diffing logic previously had a real bug: a
|
|
6
|
+
// transient snapshot fetch failure wiped `prev` entirely, causing every
|
|
7
|
+
// still-active job to spuriously re-announce as "new" (from: null) on
|
|
8
|
+
// the next successful poll even though nothing had actually changed.
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
10
|
+
import {
|
|
11
|
+
pollingJobEvents,
|
|
12
|
+
POLL_INTERVAL_FALLBACK_MS,
|
|
13
|
+
type Transport,
|
|
14
|
+
type TransportResponse,
|
|
15
|
+
} from "./transport.js";
|
|
16
|
+
import type { JobStateSnapshot } from "./codegen/schema.js";
|
|
17
|
+
|
|
18
|
+
function snapshot(
|
|
19
|
+
overrides: Partial<JobStateSnapshot> & Pick<JobStateSnapshot, "job_id">,
|
|
20
|
+
): JobStateSnapshot {
|
|
21
|
+
return {
|
|
22
|
+
session_id: null,
|
|
23
|
+
job_type: "ScanDirectory",
|
|
24
|
+
status: "pending",
|
|
25
|
+
entity_ref: null,
|
|
26
|
+
created_by: null,
|
|
27
|
+
last_stage: null,
|
|
28
|
+
last_message: null,
|
|
29
|
+
updated_at: 0,
|
|
30
|
+
...overrides,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** a fake Transport whose `request` returns one canned snapshot list (or
|
|
35
|
+
* throws) per call, in order. the last entry repeats for any extra calls. */
|
|
36
|
+
function fakeTransport(responses: Array<JobStateSnapshot[] | Error>): Transport {
|
|
37
|
+
let call = 0;
|
|
38
|
+
const request = vi.fn(async (): Promise<TransportResponse> => {
|
|
39
|
+
const entry = responses[Math.min(call, responses.length - 1)];
|
|
40
|
+
call++;
|
|
41
|
+
if (entry instanceof Error) throw entry;
|
|
42
|
+
return { status: 200, body: JSON.stringify({ success: true, data: entry }) };
|
|
43
|
+
});
|
|
44
|
+
return {
|
|
45
|
+
request,
|
|
46
|
+
upload: vi.fn(),
|
|
47
|
+
fetchBlob: vi.fn(),
|
|
48
|
+
getBlobUrl: vi.fn(),
|
|
49
|
+
} as unknown as Transport;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** advances the fake poll-interval timer `count` times in sequence,
|
|
53
|
+
* fully draining the microtask queue between each advance. */
|
|
54
|
+
async function advanceTicks(count: number): Promise<void> {
|
|
55
|
+
for (let i = 0; i < count; i++) {
|
|
56
|
+
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_FALLBACK_MS);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe("pollingJobEvents", () => {
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
vi.useFakeTimers();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
afterEach(() => {
|
|
66
|
+
vi.useRealTimers();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("the first tick only seeds the baseline - no event for an already-known job", async () => {
|
|
70
|
+
const transport = fakeTransport([
|
|
71
|
+
[snapshot({ job_id: "job-a", status: "running" })],
|
|
72
|
+
[snapshot({ job_id: "job-a", status: "completed" })],
|
|
73
|
+
]);
|
|
74
|
+
const iter = pollingJobEvents(transport);
|
|
75
|
+
const nextPromise = iter.next();
|
|
76
|
+
|
|
77
|
+
await advanceTicks(1);
|
|
78
|
+
const result = await nextPromise;
|
|
79
|
+
|
|
80
|
+
expect(result.done).toBe(false);
|
|
81
|
+
expect(result.value).toMatchObject({
|
|
82
|
+
kind: "status_changed",
|
|
83
|
+
job_id: "job-a",
|
|
84
|
+
from: "running",
|
|
85
|
+
to: "completed",
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("a job appearing after the baseline yields status_changed with from: null", async () => {
|
|
90
|
+
const transport = fakeTransport([
|
|
91
|
+
[],
|
|
92
|
+
[snapshot({ job_id: "job-a", status: "running" })],
|
|
93
|
+
]);
|
|
94
|
+
const iter = pollingJobEvents(transport);
|
|
95
|
+
const nextPromise = iter.next();
|
|
96
|
+
|
|
97
|
+
await advanceTicks(1);
|
|
98
|
+
const result = await nextPromise;
|
|
99
|
+
|
|
100
|
+
expect(result.value).toMatchObject({
|
|
101
|
+
kind: "status_changed",
|
|
102
|
+
job_id: "job-a",
|
|
103
|
+
from: null,
|
|
104
|
+
to: "running",
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("a stage/message change yields a stage event", async () => {
|
|
109
|
+
const transport = fakeTransport([
|
|
110
|
+
[snapshot({ job_id: "job-a", status: "running", last_stage: "scanning" })],
|
|
111
|
+
[
|
|
112
|
+
snapshot({
|
|
113
|
+
job_id: "job-a",
|
|
114
|
+
status: "running",
|
|
115
|
+
last_stage: "importing",
|
|
116
|
+
last_message: "42 files",
|
|
117
|
+
}),
|
|
118
|
+
],
|
|
119
|
+
]);
|
|
120
|
+
const iter = pollingJobEvents(transport);
|
|
121
|
+
const nextPromise = iter.next();
|
|
122
|
+
|
|
123
|
+
await advanceTicks(1);
|
|
124
|
+
const result = await nextPromise;
|
|
125
|
+
|
|
126
|
+
expect(result.value).toMatchObject({
|
|
127
|
+
kind: "stage",
|
|
128
|
+
job_id: "job-a",
|
|
129
|
+
stage: "importing",
|
|
130
|
+
message: "42 files",
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("a transient fetch failure between two ticks does not wipe tracked state (regression)", async () => {
|
|
135
|
+
const transport = fakeTransport([
|
|
136
|
+
[snapshot({ job_id: "job-a", status: "running" })], // tick 1: seed
|
|
137
|
+
new Error("network blip"), // tick 2: transient failure
|
|
138
|
+
[snapshot({ job_id: "job-a", status: "running" })], // tick 3: unchanged
|
|
139
|
+
[snapshot({ job_id: "job-a", status: "completed" })], // tick 4: the only real change
|
|
140
|
+
]);
|
|
141
|
+
const iter = pollingJobEvents(transport);
|
|
142
|
+
const nextPromise = iter.next();
|
|
143
|
+
|
|
144
|
+
// if the failed tick wiped `prev`, job-a would look "new" again on
|
|
145
|
+
// tick 3 (an unchanged snapshot) and this promise would already have
|
|
146
|
+
// resolved with a spurious `from: null` event after just 2 advances.
|
|
147
|
+
await advanceTicks(3);
|
|
148
|
+
const result = await nextPromise;
|
|
149
|
+
|
|
150
|
+
expect(result.value).toMatchObject({
|
|
151
|
+
kind: "status_changed",
|
|
152
|
+
job_id: "job-a",
|
|
153
|
+
from: "running",
|
|
154
|
+
to: "completed",
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("a disappearing job_id is silently dropped, not yielded", async () => {
|
|
159
|
+
const transport = fakeTransport([
|
|
160
|
+
[snapshot({ job_id: "job-a", status: "completed" })],
|
|
161
|
+
[], // job-a no longer present
|
|
162
|
+
[snapshot({ job_id: "job-b", status: "running" })],
|
|
163
|
+
]);
|
|
164
|
+
const iter = pollingJobEvents(transport);
|
|
165
|
+
const nextPromise = iter.next();
|
|
166
|
+
|
|
167
|
+
await advanceTicks(2);
|
|
168
|
+
const result = await nextPromise;
|
|
169
|
+
|
|
170
|
+
// the only event ever produced is job-b appearing - job-a's
|
|
171
|
+
// disappearance after tick 2 produced nothing.
|
|
172
|
+
expect(result.value).toMatchObject({
|
|
173
|
+
kind: "status_changed",
|
|
174
|
+
job_id: "job-b",
|
|
175
|
+
from: null,
|
|
176
|
+
to: "running",
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("stops promptly when the abort signal fires", async () => {
|
|
181
|
+
const controller = new AbortController();
|
|
182
|
+
const transport = fakeTransport([[snapshot({ job_id: "job-a", status: "running" })]]);
|
|
183
|
+
const iter = pollingJobEvents(transport, undefined, controller.signal);
|
|
184
|
+
|
|
185
|
+
const nextPromise = iter.next();
|
|
186
|
+
controller.abort();
|
|
187
|
+
await advanceTicks(1);
|
|
188
|
+
const result = await nextPromise;
|
|
189
|
+
|
|
190
|
+
expect(result.done).toBe(true);
|
|
191
|
+
});
|
|
192
|
+
});
|